+
+pkgname=python-fabric-git
+reponame=fabric
+pkgver=0.0.1
+pkgrel=1
+pkgdesc="next-gen framework for building desktop widgets using python"
+arch=(any)
+url="https://github.com/Fabric-Development/fabric"
+license=("AGPL-3.0-or-later")
+groups=()
+depends=(
+ gtk3
+ cairo
+ gtk-layer-shell
+ libgirepository
+ gobject-introspection
+ gobject-introspection-runtime
+ webkit2gtk-4.1
+ libdbusmenu-gtk3
+ python
+ python-pip
+ python-gobject
+ python-cairo
+ python-loguru
+ python-click
+ pkgconf
+)
+makedepends=(
+ git
+ python-setuptools
+)
+optdepends=(
+ "python-psutil: for system stats, this package is a dependency of the bar example file"
+)
+provides=(
+ python-fabric
+)
+conflicts=(
+ python-fabric
+)
+
+source=(git+https://github.com/Fabric-Development/$reponame.git)
+sha256sums=("SKIP")
+
+pkgver() {
+ cd "$srcdir/$reponame"
+ printf "%s.r%s.%s" "$pkgver" "$(git rev-list --count HEAD)" "$(git rev-parse --short=7 HEAD)"
+}
+
+build() {
+ cd "$srcdir/$reponame"
+ python setup.py build
+}
+
+package() {
+ cd "$srcdir/$reponame"
+ python setup.py install --root="$pkgdir/" --optimize=1
+}
diff --git a/fabric/README.md b/fabric/README.md
new file mode 100644
index 0000000..14bd09e
--- /dev/null
+++ b/fabric/README.md
@@ -0,0 +1,43 @@
+
+
+Ricing, Made Easy 🍚⚡
+
+[
Wiki Pages
](https://wiki.ffpy.org) [
Discord Server
](https://discord.gg/3sDbYc9SZP)
+
+
+
+---
+
+## What's Fabric?
+
+Fabric is a Desktop Widgets System that you can customize using Python. It offers a variety of features to make the often tedious process of creating widgets much easier and more enjoyable!
+
+Our goal is to provide a high-level, signal-based workflow—no polling or bash scripting required for even the **most basic** tasks.
+
+## Key Features
+
+1. **Simple** yet **Powerful**
+2. Supports both **X11 and Wayland**
+3. Access to **all other Python modules**
+4. Excellent **Developer Experience (DevEX)** and typing support
+5. **Low resource usage**
+ - Built-in Python code replaces the need for resource-heavy shell scripts
+
+## Visual Tour
+
+A Collection of Desktop rices that muscle-show what you are able to do using Fabric!
+
+- Example files
+ 
+- [Ax-Shell](https://github.com/axenide/Ax-Shell)
+ 
+- [HydePanel](https://github.com/rubiin/HyDePanel)
+ 
+- [Spacerice](https://github.com/SlumberDemon/dotfiles/tree/spacerice)
+ 
+- [Fabrika - فابريكا](https://example.com)
+ 
+
+## Hacking and Contributing
+
+Interested in contributing? Check out the full guide in [our wiki pages](https://wiki.ffpy.org/contributing/hacking-guide/).
diff --git a/fabric/assets/example-files-bar-showcase.png b/fabric/assets/example-files-bar-showcase.png
new file mode 100644
index 0000000..cb11bcb
Binary files /dev/null and b/fabric/assets/example-files-bar-showcase.png differ
diff --git a/fabric/assets/example-files-vertical-bar-showcase.png b/fabric/assets/example-files-vertical-bar-showcase.png
new file mode 100644
index 0000000..f242612
Binary files /dev/null and b/fabric/assets/example-files-vertical-bar-showcase.png differ
diff --git a/fabric/default.nix b/fabric/default.nix
new file mode 100644
index 0000000..d343f1c
--- /dev/null
+++ b/fabric/default.nix
@@ -0,0 +1,50 @@
+{
+ lib,
+ python312Packages,
+ gtk3,
+ gtk-layer-shell,
+ cairo,
+ gobject-introspection,
+ libdbusmenu-gtk3,
+ gdk-pixbuf,
+ cinnamon-desktop,
+ gnome-bluetooth,
+}:
+python312Packages.buildPythonPackage {
+ pname = "python-fabric";
+ version = "0.0.2";
+ pyproject = true;
+
+ src = ./.;
+
+ buildInputs = [
+ gtk3
+ gtk-layer-shell
+ cairo
+ gobject-introspection
+ libdbusmenu-gtk3
+ gdk-pixbuf
+ cinnamon-desktop
+ gnome-bluetooth
+ ];
+
+ dependencies = with python312Packages; [
+ setuptools
+ click
+ pycairo
+ pygobject3
+ pygobject-stubs
+ loguru
+ psutil
+ ];
+
+ meta = {
+ changelog = "";
+ description = ''
+ next-gen framework for building desktop widgets using Python (check the rewrite branch for progress)
+ '';
+ homepage = "https://github.com/Fabric-Development/fabric";
+ license = lib.licenses.agpl3Plus;
+ maintainers = with lib.maintainers; [];
+ };
+}
diff --git a/fabric/examples/app-launcher/config.py b/fabric/examples/app-launcher/config.py
new file mode 100644
index 0000000..dd5640e
--- /dev/null
+++ b/fabric/examples/app-launcher/config.py
@@ -0,0 +1,149 @@
+"""
+example configuration shows how to make a simple
+desktop applications launcher, this example doesn't involve
+any styling (except a couple of basic style properties)
+
+
+the purpose of this configuration is to show to to use
+the given utils and mainly how using lazy executors might
+make the configuration way more faster than it's supposed to be
+"""
+
+import operator
+from collections.abc import Iterator
+from fabric import Application
+from fabric.widgets.box import Box
+from fabric.widgets.label import Label
+from fabric.widgets.button import Button
+from fabric.widgets.image import Image
+from fabric.widgets.entry import Entry
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.widgets.wayland import WaylandWindow as Window
+from fabric.utils import DesktopApp, get_desktop_applications, idle_add, remove_handler
+
+
+class AppLauncher(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ layer="top",
+ anchor="center",
+ exclusivity="none",
+ keyboard_mode="on-demand",
+ visible=False,
+ all_visible=False,
+ **kwargs,
+ )
+ self._arranger_handler: int = 0
+ self._all_apps = get_desktop_applications()
+
+ self.viewport = Box(spacing=2, orientation="v")
+ self.search_entry = Entry(
+ placeholder="Search Applications...",
+ h_expand=True,
+ notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
+ )
+ self.scrolled_window = ScrolledWindow(
+ min_content_size=(280, 320),
+ max_content_size=(280 * 2, 320),
+ child=self.viewport,
+ )
+
+ self.add(
+ Box(
+ spacing=2,
+ orientation="v",
+ style="margin: 2px",
+ children=[
+ # the header with the search entry
+ Box(
+ spacing=2,
+ orientation="h",
+ children=[
+ self.search_entry,
+ Button(
+ image=Image(icon_name="window-close"),
+ tooltip_text="Exit",
+ on_clicked=lambda *_: self.application.quit(),
+ ),
+ ],
+ ),
+ # the actual slots holder
+ self.scrolled_window,
+ ],
+ )
+ )
+ self.show_all()
+
+ def arrange_viewport(self, query: str = ""):
+ # reset everything so we can filter current viewport's slots...
+ # remove the old handler so we can avoid race conditions
+ remove_handler(self._arranger_handler) if self._arranger_handler else None
+
+ # remove all children from the viewport
+ self.viewport.children = []
+
+ # make a new iterator containing the filtered apps
+ filtered_apps_iter = iter(
+ [
+ app
+ for app in self._all_apps
+ if query.casefold()
+ in (
+ (app.display_name or "")
+ + (" " + app.name + " ")
+ + (app.generic_name or "")
+ ).casefold()
+ ]
+ )
+ should_resize = operator.length_hint(filtered_apps_iter) == len(self._all_apps)
+
+ # all aboard...
+ # start the process of adding slots with a lazy executor
+ # using this method makes the process of adding slots way more less
+ # resource expensive without blocking the main thread and resulting in a lock
+ self._arranger_handler = idle_add(
+ lambda *args: self.add_next_application(*args)
+ or (self.resize_viewport() if should_resize else False),
+ filtered_apps_iter,
+ pin=True,
+ )
+
+ return False
+
+ def add_next_application(self, apps_iter: Iterator[DesktopApp]):
+ if not (app := next(apps_iter, None)):
+ return False
+
+ self.viewport.add(self.bake_application_slot(app))
+ return True
+
+ def resize_viewport(self):
+ self.scrolled_window.set_min_content_width(
+ self.viewport.get_allocation().width # type: ignore
+ )
+ return False
+
+ def bake_application_slot(self, app: DesktopApp, **kwargs) -> Button:
+ return Button(
+ child=Box(
+ orientation="h",
+ spacing=12,
+ children=[
+ Image(pixbuf=app.get_icon_pixbuf(), h_align="start", size=32),
+ Label(
+ label=app.display_name or "Unknown",
+ v_align="center",
+ h_align="center",
+ ),
+ ],
+ ),
+ tooltip_text=app.description,
+ on_clicked=lambda *_: (app.launch(), self.application.quit()),
+ **kwargs,
+ )
+
+
+if __name__ == "__main__":
+ app_launcher = AppLauncher()
+ app = Application("app-launcher", app_launcher)
+ app.run()
diff --git a/fabric/examples/bar/config.py b/fabric/examples/bar/config.py
new file mode 100644
index 0000000..0096c57
--- /dev/null
+++ b/fabric/examples/bar/config.py
@@ -0,0 +1,158 @@
+import psutil
+from fabric import Application, Fabricator
+from fabric.widgets.box import Box
+from fabric.widgets.image import Image
+from fabric.widgets.eventbox import EventBox
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.centerbox import CenterBox
+from fabric.system_tray.widgets import SystemTray
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.wayland import WaylandWindow as Window
+from fabric.hyprland.widgets import (
+ HyprlandLanguage,
+ HyprlandActiveWindow,
+ HyprlandWorkspaces,
+ WorkspaceButton,
+)
+from fabric.utils import FormattedString, get_relative_path, bulk_replace
+
+AUDIO_WIDGET = True
+
+if AUDIO_WIDGET is True:
+ try:
+ from fabric.audio.service import Audio
+ except Exception as e:
+ AUDIO_WIDGET = False
+ print(e)
+
+
+class VolumeWidget(Box):
+ def __init__(self, **kwargs):
+ self.progress_bar = CircularProgressBar(
+ name="volume-progress-bar",
+ pie=True,
+ child=Image(icon_name="audio-speakers-symbolic", icon_size=12),
+ size=24,
+ )
+
+ self.audio = Audio(notify_speaker=self.on_speaker_changed)
+
+ super().__init__(
+ children=EventBox(
+ events="scroll", child=self.progress_bar, on_scroll_event=self.on_scroll
+ ),
+ **kwargs,
+ )
+
+ def on_scroll(self, _, event):
+ match event.direction:
+ case 0:
+ self.audio.speaker.volume += 8
+ case 1:
+ self.audio.speaker.volume -= 8
+ return
+
+ def on_speaker_changed(self):
+ if not self.audio.speaker:
+ return
+
+ self.progress_bar.value = self.audio.speaker.volume / 100
+ return self.audio.speaker.bind(
+ "volume", "value", self.progress_bar, lambda _, v: v / 100
+ )
+
+
+class StatusBar(Window):
+ def __init__(
+ self,
+ ):
+ super().__init__(
+ name="bar",
+ layer="top",
+ anchor="left top right",
+ margin="10px 10px -2px 10px",
+ exclusivity="auto",
+ visible=False,
+ )
+
+ self.system_status = Box(
+ name="system-status",
+ spacing=4,
+ orientation="h",
+ children=[
+ # a progress bar (ram) has a child of a progress bar (cpu) that which has a child (the icon)
+ CircularProgressBar(
+ name="ram-progress-bar",
+ pie=True,
+ child=CircularProgressBar(
+ name="cpu-progress-bar",
+ pie=True,
+ child=Image(icon_name="cpu-symbolic", icon_size=12),
+ size=24,
+ ).build(
+ lambda progres: Fabricator(
+ interval=1000,
+ poll_from=lambda f: psutil.cpu_percent() / 100,
+ on_changed=lambda _, value: progres.set_value(value),
+ )
+ ),
+ size=24,
+ ).build(
+ lambda progres: Fabricator(
+ interval=1000,
+ poll_from=lambda f: psutil.virtual_memory().percent / 100,
+ on_changed=lambda _, value: progres.set_value(value),
+ )
+ )
+ ]
+ # create a volume widget if enabled
+ + ([VolumeWidget()] if AUDIO_WIDGET else []),
+ )
+
+ self.children = CenterBox(
+ name="bar-inner",
+ start_children=Box(
+ name="start-container",
+ children=HyprlandWorkspaces(
+ name="workspaces",
+ spacing=4,
+ buttons_factory=lambda ws_id: WorkspaceButton(id=ws_id, label=None),
+ ),
+ ),
+ center_children=Box(
+ name="center-container",
+ children=HyprlandActiveWindow(name="hyprland-window"),
+ ),
+ end_children=Box(
+ name="end-container",
+ spacing=4,
+ orientation="h",
+ children=[
+ self.system_status,
+ SystemTray(name="system-tray", spacing=4),
+ DateTime(name="date-time"),
+ HyprlandLanguage(
+ name="hyprland-window",
+ formatter=FormattedString(
+ "{replace_lang(language)}",
+ replace_lang=lambda lang: bulk_replace(
+ lang,
+ (r".*Eng.*", r".*Ar.*"),
+ ("ENG", "ARA"),
+ regex=True,
+ ),
+ ),
+ ),
+ ],
+ ),
+ )
+
+ return self.show_all()
+
+
+if __name__ == "__main__":
+ bar = StatusBar()
+ app = Application("bar", bar)
+ app.set_stylesheet_from_file(get_relative_path("./style.css"))
+
+ app.run()
diff --git a/fabric/examples/bar/style.css b/fabric/examples/bar/style.css
new file mode 100644
index 0000000..0f66774
--- /dev/null
+++ b/fabric/examples/bar/style.css
@@ -0,0 +1,165 @@
+/* we can use web-css variables, fabric compiles those to gtk-css.
+ global variables can be defined in the :vars selector... */
+:vars {
+ --background: #150d16;
+ --foreground: #f5dbc4;
+ --color0: #150d16;
+ --color1: #72448D;
+ --color2: #9C5995;
+ --color3: #D5719F;
+ --color4: #9A62F3;
+ --color5: #E075DF;
+ --color6: #F98F9F;
+ --color7: #f5dbc4;
+ --color8: #ab9989;
+ --color9: #72448D;
+ --color10: #9C5995;
+ --color11: #D5719F;
+ --color12: #9A62F3;
+ --color13: #E075DF;
+ --color14: #F98F9F;
+ --color15: #f5dbc4;
+ --window-bg: alpha(var(--background), 0.6);
+ --module-bg: alpha(var(--background), 0.4);
+ --border-color: var(--color11);
+ --ws-active: var(--color9);
+ --ws-inactive: var(--color3);
+ --ws-empty: var(--color8);
+ --ws-hover: var(--color1);
+ --ws-urgent: var(--color12);
+}
+
+* {
+ /* unset so we can style everything from the ground up. */
+ all: unset;
+
+ /* text color */
+ color: var(--foreground);
+ font-size: 16px;
+ font-family: "Jost*", sans-serif;
+ /* pill shape */
+ border-radius: 100px;
+}
+
+#bar-inner {
+ padding: 4px;
+ border: solid 2px;
+ border-color: var(--border-color);
+ background-color: var(--window-bg);
+ min-height: 28px;
+}
+
+#workspaces {
+ padding: 6px;
+ min-width: 0px;
+ background-color: var(--module-bg);
+}
+
+#workspaces>button {
+ padding: 0px 8px;
+ transition: padding 0.05s steps(8);
+ background-color: var(--ws-inactive);
+}
+
+#workspaces>button>label {
+ font-size: 0px;
+}
+
+#workspaces>button:hover {
+ background-color: var(--ws-hover);
+}
+
+#workspaces>button.urgent {
+ background-color: var(--ws-urgent);
+}
+
+#workspaces>button.active {
+ padding: 0px 32px;
+ background-color: var(--ws-active);
+}
+
+#workspaces>button.empty {
+ background-color: var(--ws-empty);
+}
+
+
+#system-status {
+ padding: 2px;
+ background-color: var(--module-bg);
+}
+
+#cpu-progress-bar,
+#ram-progress-bar,
+#volume-progress-bar {
+ color: transparent;
+ background-color: transparent
+}
+
+#cpu-progress-bar {
+ border: solid 0px alpha(var(--color9), 0.8);
+}
+
+#ram-progress-bar,
+#volume-progress-bar {
+ border: solid 0px var(--border-color);
+}
+
+#date-time,
+#hyprland-language,
+#hyprland-window {
+ background-color: var(--module-bg);
+ padding: 0px 8px;
+}
+
+menu>menuitem>label,
+#date-time>label,
+#hyprland-language>label,
+#hyprland-window>label {
+ font-weight: 900;
+}
+
+/* system tray */
+#system-tray {
+ padding: 2px 4px;
+ background-color: var(--module-bg);
+}
+
+/* menu and menu items (written for the system tray) */
+menu {
+ border: solid 2px;
+ border-radius: 10px;
+ border-color: var(--border-color);
+ background-color: var(--window-bg);
+}
+
+menu>menuitem {
+ border-radius: 0px;
+ background-color: var(--module-bg);
+ padding: 6px;
+ margin-left: 2px;
+ margin-right: 2px;
+}
+
+menu>menuitem:first-child {
+ margin-top: 1px;
+ border-radius: 8px 8px 0px 0px;
+}
+
+menu>menuitem:last-child {
+ margin-bottom: 1px;
+ border-radius: 0px 0px 8px 8px;
+}
+
+menu>menuitem:hover {
+ background-color: var(--border-color);
+}
+
+tooltip {
+ border: solid 2px;
+ border-color: var(--border-color);
+ background-color: var(--window-bg);
+}
+
+tooltip>* {
+ padding: 2px 4px
+}
\ No newline at end of file
diff --git a/fabric/examples/bluetooth-widget/config.py b/fabric/examples/bluetooth-widget/config.py
new file mode 100644
index 0000000..cbd3fc5
--- /dev/null
+++ b/fabric/examples/bluetooth-widget/config.py
@@ -0,0 +1,112 @@
+from fabric import Application
+from fabric.widgets.box import Box
+from fabric.widgets.label import Label
+from fabric.widgets.image import Image
+from fabric.widgets.button import Button
+from fabric.widgets.window import Window
+from fabric.widgets.centerbox import CenterBox
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.bluetooth import BluetoothClient, BluetoothDevice
+
+
+class BluetoothDeviceSlot(CenterBox):
+ def __init__(self, device: BluetoothDevice, **kwargs):
+ super().__init__(**kwargs)
+ self.device = device
+ self.device.connect("changed", self.on_changed)
+ self.device.connect(
+ "notify::closed", lambda *_: self.device.closed and self.destroy()
+ )
+
+ self.connection_label = Label(label="Disconnected")
+ self.connect_button = Button(
+ label="Connect",
+ on_clicked=lambda *_: self.device.set_connecting(not self.device.connected),
+ )
+
+ self.start_children = [
+ Image(icon_name=device.icon_name + "-symbolic", size=32),
+ Label(label=device.name),
+ ]
+ self.center_children = self.connection_label
+ self.end_children = self.connect_button
+
+ self.device.emit("changed") # to update display status
+
+ def on_changed(self, *_):
+ self.connection_label.set_label(
+ "Connected" if self.device.connected else "Disconnected"
+ )
+ if self.device.connecting:
+ self.connect_button.set_label(
+ "Connecting..." if not self.device.connecting else "Disconnecting..."
+ )
+ else:
+ self.connect_button.set_label(
+ "Connect" if not self.device.connected else "Disconnect"
+ )
+ return
+
+
+class BluetoothConnections(Box):
+ def __init__(self, **kwargs):
+ super().__init__(
+ spacing=4,
+ orientation="vertical",
+ style="margin: 8px",
+ **kwargs,
+ )
+
+ self.client = BluetoothClient(on_device_added=self.on_device_added)
+ self.scan_button = Button(on_clicked=lambda *_: self.client.toggle_scan())
+ self.toggle_button = Button(on_clicked=lambda *_: self.client.toggle_power())
+
+ self.client.connect(
+ "notify::enabled",
+ lambda *_: self.toggle_button.set_label(
+ "Bluetooth " + ("ON" if self.client.enabled else "OFF")
+ ),
+ )
+ self.client.connect(
+ "notify::scanning",
+ lambda *_: self.scan_button.set_label(
+ "Stop" if self.client.scanning else "Scan"
+ ),
+ )
+
+ self.paired_box = Box(spacing=2, orientation="vertical")
+ self.available_box = Box(spacing=2, orientation="vertical")
+
+ self.children = [
+ CenterBox(start_children=self.scan_button, end_children=self.toggle_button),
+ Label("Paired Devices"),
+ ScrolledWindow(min_content_size=(400, -1), child=self.paired_box),
+ Label("Available Devices"),
+ ScrolledWindow(min_content_size=(-1, 400), child=self.available_box),
+ ]
+
+ # to run notify closures thus display the status
+ # without having to wait until an actual change
+ self.client.notify("scanning")
+ self.client.notify("enabled")
+
+ def on_device_added(self, client: BluetoothClient, address: str):
+ if not (device := client.get_device(address)):
+ return
+ slot = BluetoothDeviceSlot(device)
+
+ if device.paired:
+ return self.paired_box.add(slot)
+ return self.available_box.add(slot)
+
+
+if __name__ == "__main__":
+ bluetooth_widget = Window(
+ child=BluetoothConnections(),
+ visible=True,
+ all_visible=True,
+ on_destroy=lambda *_: app.quit(),
+ )
+ app = Application("bluetooth-widget", bluetooth_widget)
+
+ app.run()
diff --git a/fabric/examples/desktop-widget/config.py b/fabric/examples/desktop-widget/config.py
new file mode 100644
index 0000000..ac68b7f
--- /dev/null
+++ b/fabric/examples/desktop-widget/config.py
@@ -0,0 +1,26 @@
+from fabric import Application
+from fabric.widgets.box import Box
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.wayland import WaylandWindow as Window
+from fabric.utils import get_relative_path
+
+
+if __name__ == "__main__":
+ desktop_widget = Window(
+ layer="bottom",
+ anchor="center", # FYI: there's no anchor named "center" (anchor of "" is == to "center")
+ exclusivity="none",
+ child=Box(
+ orientation="v",
+ children=[
+ DateTime(formatters=["%A. %d %B"], interval=10000, name="date"),
+ DateTime(formatters=["%I:%M"], name="clock"),
+ ],
+ ),
+ all_visible=True,
+ )
+
+ app = Application("desktop-widget", desktop_widget)
+ app.set_stylesheet_from_file(get_relative_path("./style.css"))
+
+ app.run()
diff --git a/fabric/examples/desktop-widget/style.css b/fabric/examples/desktop-widget/style.css
new file mode 100644
index 0000000..936c1e4
--- /dev/null
+++ b/fabric/examples/desktop-widget/style.css
@@ -0,0 +1,38 @@
+:vars {
+ --background: #150d16;
+ --foreground: #f5dbc4;
+ --color0: #150d16;
+ --color1: #72448D;
+ --color2: #9C5995;
+ --color3: #D5719F;
+ --color4: #9A62F3;
+ --color5: #E075DF;
+ --color6: #F98F9F;
+ --color7: #f5dbc4;
+ --color8: #ab9989;
+ --color9: #72448D;
+ --color10: #9C5995;
+ --color11: #D5719F;
+ --color12: #9A62F3;
+ --color13: #E075DF;
+ --color14: #F98F9F;
+ --color15: #f5dbc4;
+}
+
+* {
+ all: unset;
+ transition: color 0.5s ease;
+ color: alpha(var(--foreground), 0.8);
+ font-family: "Jost*", sans-serif;
+ font-weight: 700;
+}
+
+#clock>label {
+ font-size: 86px;
+ font-weight: 900;
+}
+
+#date>label {
+ font-size: 24px;
+ margin-bottom: -18px;
+}
diff --git a/fabric/examples/icon-browser/config.py b/fabric/examples/icon-browser/config.py
new file mode 100644
index 0000000..482eb97
--- /dev/null
+++ b/fabric/examples/icon-browser/config.py
@@ -0,0 +1,105 @@
+from fabric import Application
+from collections.abc import Iterator
+from fabric.widgets.box import Box
+from fabric.widgets.entry import Entry
+from fabric.widgets.image import Image
+from fabric.widgets.button import Button
+from fabric.widgets.window import Window
+from fabric.widgets.flowbox import FlowBox
+from fabric.widgets.scrolledwindow import ScrolledWindow
+from fabric.utils import idle_add, remove_handler
+
+from gi.repository import Gtk
+
+
+class IconBrowser(Window):
+ def __init__(self, **kwargs):
+ super().__init__(
+ layer="top",
+ anchor="center",
+ exclusivity="none",
+ keyboard_mode="on-demand",
+ visible=False,
+ all_visible=False,
+ on_destroy=lambda *_: app.quit(),
+ **kwargs,
+ )
+ self._arranger_handler: int = 0
+ self._icon_theme = Gtk.IconTheme.get_default()
+ self._all_icons: list[str] = self._icon_theme.list_icons()
+
+ self.viewport = FlowBox(orientation="v")
+ self.search_entry = Entry(
+ placeholder="Search Icons...",
+ h_expand=True,
+ notify_text=lambda entry, *_: self.arrange_viewport(entry.get_text()),
+ )
+ self.scrolled_window = ScrolledWindow(
+ min_content_size=(280, 320), child=self.viewport
+ )
+
+ self.add(
+ Box(
+ spacing=2,
+ orientation="v",
+ style="margin: 2px",
+ children=[
+ # the header with the search entry
+ Box(
+ spacing=2,
+ orientation="h",
+ children=[
+ self.search_entry,
+ Button(
+ image=Image(icon_name="window-close"),
+ tooltip_text="Exit",
+ on_clicked=lambda *_: self.application.quit(),
+ ),
+ ],
+ ),
+ # the actual slots holder
+ self.scrolled_window,
+ ],
+ )
+ )
+ self.show_all()
+
+ def arrange_viewport(self, query: str = ""):
+ if self._arranger_handler:
+ remove_handler(self._arranger_handler)
+
+ self.viewport.children = []
+ self._arranger_handler = idle_add(
+ self.add_next_icon,
+ iter(
+ sorted(
+ icon_name
+ for icon_name in self._all_icons
+ if query.casefold() in icon_name.casefold()
+ )
+ ),
+ pin=True,
+ )
+
+ return False
+
+ def add_next_icon(self, icons_iter: Iterator[str]):
+ if not (icon_name := next(icons_iter, None)):
+ return False
+
+ self.viewport.add(
+ Button(
+ child=Image(
+ icon_name=icon_name, icon_size=32, h_align="center", size=32
+ ),
+ tooltip_text=icon_name,
+ )
+ )
+ return True
+
+
+if __name__ == "__main__":
+ icon_browser = IconBrowser()
+ app = Application("icon-browser", icon_browser)
+
+ app.run()
diff --git a/fabric/examples/notifications/config.py b/fabric/examples/notifications/config.py
new file mode 100644
index 0000000..de4995a
--- /dev/null
+++ b/fabric/examples/notifications/config.py
@@ -0,0 +1,162 @@
+from typing import cast
+
+from fabric import Application
+from fabric.widgets.box import Box
+from fabric.widgets.label import Label
+from fabric.widgets.image import Image
+from fabric.widgets.button import Button
+from fabric.widgets.wayland import WaylandWindow
+from fabric.notifications import Notifications, Notification
+from fabric.utils import invoke_repeater, get_relative_path
+
+from gi.repository import GdkPixbuf
+
+
+NOTIFICATION_WIDTH = 360
+NOTIFICATION_IMAGE_SIZE = 64
+NOTIFICATION_TIMEOUT = 10 * 1000 # 10 seconds
+
+
+class NotificationWidget(Box):
+ def __init__(self, notification: Notification, **kwargs):
+ super().__init__(
+ size=(NOTIFICATION_WIDTH, -1),
+ name="notification",
+ spacing=8,
+ orientation="v",
+ **kwargs,
+ )
+
+ self._notification = notification
+
+ body_container = Box(spacing=4, orientation="h")
+
+ if image_pixbuf := self._notification.image_pixbuf:
+ body_container.add(
+ Image(
+ pixbuf=image_pixbuf.scale_simple(
+ NOTIFICATION_IMAGE_SIZE,
+ NOTIFICATION_IMAGE_SIZE,
+ GdkPixbuf.InterpType.BILINEAR,
+ )
+ )
+ )
+
+ body_container.add(
+ Box(
+ spacing=4,
+ orientation="v",
+ children=[
+ # a box for holding both the "summary" label and the "close" button
+ Box(
+ orientation="h",
+ children=[
+ Label(
+ label=self._notification.summary,
+ ellipsization="middle",
+ )
+ .build()
+ .add_style_class("summary")
+ .unwrap(),
+ ],
+ h_expand=True,
+ v_expand=True,
+ )
+ # add the "close" button
+ .build(
+ lambda box, _: box.pack_end(
+ Button(
+ image=Image(
+ icon_name="close-symbolic",
+ icon_size=18,
+ ),
+ v_align="center",
+ h_align="end",
+ on_clicked=lambda *_: self._notification.close(),
+ ),
+ False,
+ False,
+ 0,
+ )
+ ),
+ Label(
+ label=self._notification.body,
+ line_wrap="word-char",
+ v_align="start",
+ h_align="start",
+ )
+ .build()
+ .add_style_class("body")
+ .unwrap(),
+ ],
+ h_expand=True,
+ v_expand=True,
+ )
+ )
+
+ self.add(body_container)
+
+ if actions := self._notification.actions:
+ self.add(
+ Box(
+ spacing=4,
+ orientation="h",
+ children=[
+ Button(
+ h_expand=True,
+ v_expand=True,
+ label=action.label,
+ on_clicked=lambda *_, action=action: action.invoke(),
+ )
+ for action in actions
+ ],
+ )
+ )
+
+ # destroy this widget once the notification is closed
+ self._notification.connect(
+ "closed",
+ lambda *_: (
+ parent.remove(self) if (parent := self.get_parent()) else None, # type: ignore
+ self.destroy(),
+ ),
+ )
+
+ # automatically close the notification after the timeout period
+ invoke_repeater(
+ NOTIFICATION_TIMEOUT,
+ lambda: self._notification.close("expired"),
+ initial_call=False,
+ )
+
+
+if __name__ == "__main__":
+ app = Application(
+ "notifications",
+ WaylandWindow(
+ margin="8px 8px 8px 8px",
+ anchor="top right",
+ child=Box(
+ size=2, # so it's not ignored by the compositor
+ spacing=4,
+ orientation="v",
+ ).build(
+ lambda viewport, _: Notifications(
+ on_notification_added=lambda notifs_service, nid: viewport.add(
+ NotificationWidget(
+ cast(
+ Notification,
+ notifs_service.get_notification_from_id(nid),
+ )
+ )
+ )
+ )
+ ),
+ visible=True,
+ all_visible=True,
+ ),
+ )
+
+ app.set_stylesheet_from_file(get_relative_path("./style.css"))
+
+ app.run()
diff --git a/fabric/examples/notifications/style.css b/fabric/examples/notifications/style.css
new file mode 100644
index 0000000..2954097
--- /dev/null
+++ b/fabric/examples/notifications/style.css
@@ -0,0 +1,40 @@
+:vars {
+ --foreground: rgb(255, 255, 255);
+ --background: rgb(26, 28, 30);
+ --border-color: rgb(42, 43, 45);
+ --button-color: var(--border-color);
+}
+
+* {
+ all: unset;
+ color: var(--foreground);
+ font-family: "Jost*", "Cantarell", "Helvetica", "Arial", sans-serif;
+}
+
+#notification {
+ padding: 0.8rem;
+ border: solid 1px var(--border-color);
+ border-radius: 1rem;
+ background-color: var(--background);
+}
+
+#notification .summary {
+ font-size: 20px;
+ font-weight: bold;
+}
+
+#notification .body {
+ color: darker(var(--foreground));
+ font-weight: normal;
+}
+
+button {
+ padding: 0.6rem;
+ font-weight: 600;
+ border-radius: 3rem;
+ background-color: var(--button-color);
+}
+
+button:hover {
+ background-color: lighter(var(--button-color));
+}
diff --git a/fabric/examples/side-panel/config.py b/fabric/examples/side-panel/config.py
new file mode 100644
index 0000000..b2127a5
--- /dev/null
+++ b/fabric/examples/side-panel/config.py
@@ -0,0 +1,174 @@
+"""side panel example, contains info about the system"""
+
+import os
+import time
+import psutil
+from loguru import logger
+from fabric import Application
+from fabric.widgets.box import Box
+from fabric.widgets.label import Label
+from fabric.widgets.overlay import Overlay
+from fabric.widgets.datetime import DateTime
+from fabric.widgets.circularprogressbar import CircularProgressBar
+from fabric.widgets.wayland import WaylandWindow as Window
+from fabric.utils import invoke_repeater, get_relative_path
+
+
+def get_profile_picture_path() -> str | None:
+ path = os.path.expanduser("~/Pictures/Other/face.jpg")
+ if not os.path.exists(path):
+ path = os.path.expanduser("~/.face")
+ if not os.path.exists(path):
+ logger.warning(
+ "can't fetch a user profile picture, add a profile picture image at ~/.face or at ~/Pictures/Other/profile.jpg"
+ )
+ path = None
+ return path
+
+
+class SidePanel(Window):
+ @staticmethod
+ def bake_progress_bar(name: str = "progress-bar", size: int = 64, **kwargs):
+ return CircularProgressBar(
+ name=name, min_value=0, max_value=100, size=size, **kwargs
+ )
+
+ @staticmethod
+ def bake_progress_icon(**kwargs):
+ return Label(**kwargs).build().add_style_class("progress-icon").unwrap()
+
+ def __init__(self, **kwargs):
+ super().__init__(
+ layer="overlay",
+ title="fabric-overlay",
+ anchor="top right",
+ margin="10px 10px 10px 0px",
+ exclusivity="none",
+ visible=False,
+ all_visible=False,
+ **kwargs,
+ )
+
+ self.profile_pic = Box(
+ name="profile-pic",
+ style=f'background-image: url("file://{get_profile_picture_path() or ""}")',
+ )
+ self.uptime_label = Label(label=f"{self.get_current_uptime()}")
+
+ self.header = Box(
+ spacing=14,
+ name="header",
+ orientation="h",
+ children=[
+ self.profile_pic,
+ Box(
+ orientation="v",
+ children=[
+ DateTime(
+ name="date-time",
+ style="margin-top: 4px; min-width: 180px;",
+ ),
+ self.uptime_label,
+ ],
+ ),
+ ],
+ )
+
+ self.greeter_label = Label(
+ label=f"Good {'Morning' if time.localtime().tm_hour < 12 else 'Afternoon'}, {os.getlogin().title()}!",
+ style="font-size: 20px;",
+ )
+
+ self.cpu_progress = self.bake_progress_bar()
+ self.ram_progress = self.bake_progress_bar()
+ self.bat_circular = self.bake_progress_bar().build().set_value(42).unwrap()
+
+ self.progress_container = Box(
+ name="progress-bar-container",
+ spacing=12,
+ children=[
+ Box(
+ children=[
+ Overlay(
+ child=self.cpu_progress,
+ overlays=[
+ self.bake_progress_icon(
+ label="",
+ style="margin-right: 8px; text-shadow: 0 0 10px #fff, 0 0 10px #fff, 0 0 10px #fff;",
+ )
+ ],
+ ),
+ ],
+ ),
+ Box(name="progress-bar-sep"),
+ Box(
+ children=[
+ Overlay(
+ child=self.ram_progress,
+ overlays=[
+ self.bake_progress_icon(
+ label="",
+ style="margin-right: 4px; text-shadow: 0 0 10px #fff;",
+ )
+ ],
+ )
+ ]
+ ),
+ Box(name="progress-bar-sep"),
+ Box(
+ children=[
+ Overlay(
+ child=self.bat_circular,
+ overlays=[
+ self.bake_progress_icon(
+ label="",
+ style="margin-right: 0px; text-shadow: 0 0 10px #fff, 0 0 18px #fff;",
+ )
+ ],
+ ),
+ ],
+ ),
+ ],
+ )
+
+ self.update_status()
+ invoke_repeater(
+ 15 * 60 * 1000, # every 15min
+ lambda: (self.uptime_label.set_label(self.get_current_uptime()), True)[1],
+ )
+ invoke_repeater(1000, self.update_status)
+
+ self.add(
+ Box(
+ name="window-inner",
+ orientation="v",
+ spacing=24,
+ children=[self.header, self.greeter_label, self.progress_container],
+ ),
+ )
+ self.show_all()
+
+ def update_status(self):
+ self.cpu_progress.value = psutil.cpu_percent()
+ self.ram_progress.value = psutil.virtual_memory().percent
+ if not (bat_sen := psutil.sensors_battery()):
+ self.bat_circular.value = 42
+ else:
+ self.bat_circular.value = bat_sen.percent
+
+ return True
+
+ def get_current_uptime(self):
+ uptime = time.time() - psutil.boot_time()
+ uptime_days, remainder = divmod(uptime, 86400)
+ uptime_hours, remainder = divmod(remainder, 3600)
+ # uptime_minutes, _ = divmod(remainder, 60)
+ return f"{int(uptime_days)} {'days' if uptime_days > 1 else 'day'}, {int(uptime_hours)} {'hours' if uptime_hours > 1 else 'hour'}"
+
+
+if __name__ == "__main__":
+ side_panel = SidePanel()
+ app = Application("side-panel", side_panel)
+ app.set_stylesheet_from_file(get_relative_path("./style.css"))
+
+ app.run()
diff --git a/fabric/examples/side-panel/style.css b/fabric/examples/side-panel/style.css
new file mode 100644
index 0000000..dd46df6
--- /dev/null
+++ b/fabric/examples/side-panel/style.css
@@ -0,0 +1,83 @@
+:vars {
+ --background: #150d16;
+ --foreground: #f5dbc4;
+ --color0: #150d16;
+ --color1: #72448D;
+ --color2: #9C5995;
+ --color3: #D5719F;
+ --color4: #9A62F3;
+ --color5: #E075DF;
+ --color6: #F98F9F;
+ --color7: #f5dbc4;
+ --color8: #ab9989;
+ --color9: #72448D;
+ --color10: #9C5995;
+ --color11: #D5719F;
+ --color12: #9A62F3;
+ --color13: #E075DF;
+ --color14: #F98F9F;
+ --color15: #f5dbc4;
+ --window-bg: alpha(var(--background), 0.6);
+ --module-bg: alpha(var(--background), 0.4);
+ --border-color: var(--color11);
+}
+
+* {
+ all: unset;
+ font-family: "Jost*";
+ font-weight: 500;
+ font-size: 18px;
+ color: var(--foreground);
+}
+
+#window-inner {
+ border: solid 2px;
+ border-color: var(--color11);
+ background-color: var(--window-bg);
+ border-radius: 100px;
+ border-radius: 12px;
+ padding: 10px;
+}
+
+#date-time>label {
+ font-weight: 900;
+ font-size: 28px;
+}
+
+#header {
+ border: solid 1px var(--border-color);
+ border-radius: 8px;
+ box-shadow: 0px 18px 23px -6px rgba(0, 0, 0, 0.75);
+ background: linear-gradient(90deg,
+ alpha(var(--color11), 0.2),
+ alpha(var(--background), 0.2),
+ var(--module-bg));
+}
+
+#profile-pic {
+ background-color: alpha(var(--color11), 0.2);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+ border-radius: 6px;
+ padding: 32px;
+ margin: 4px;
+}
+
+#progress-bar {
+ color: var(--module-bg);
+ border: solid 10px var(--border-color);
+}
+
+#progress-bar-sep {
+ padding: 4px;
+ margin: 16px 0px;
+ border-radius: 100px;
+ background-color: var(--module-bg);
+}
+
+#progress-bar-container {
+ background-color: var(--module-bg);
+ border-radius: 100px;
+ padding: 8px;
+}
diff --git a/fabric/fabric/__init__.py b/fabric/fabric/__init__.py
new file mode 100644
index 0000000..4101365
--- /dev/null
+++ b/fabric/fabric/__init__.py
@@ -0,0 +1,5 @@
+from fabric.core.service import Service, Signal, Property, Builder
+from fabric.core.application import Application
+from fabric.core.fabricator import Fabricator
+
+__all__ = ["Service", "Signal", "Property", "Builder", "Application", "Fabricator"]
diff --git a/fabric/fabric/__main__.py b/fabric/fabric/__main__.py
new file mode 100644
index 0000000..8a0c40f
--- /dev/null
+++ b/fabric/fabric/__main__.py
@@ -0,0 +1,218 @@
+import click
+from json import dumps as serialize_json
+from gi.repository import GLib, Gio
+
+FABRIC_DBUS_INTERFACE_NAME = "org.Fabric.fabric"
+FABRIC_DBUS_OBJECT_PATH = "/org/Fabric/fabric"
+
+
+def get_dbus_names() -> GLib.Variant:
+ return (
+ Gio.bus_get_sync(Gio.BusType.SESSION)
+ .call_sync(
+ "org.freedesktop.DBus",
+ "/org/freedesktop/DBus",
+ "org.freedesktop.DBus",
+ "ListNames",
+ GLib.Variant("()", ()),
+ GLib.VariantType("(as)"), # type: ignore
+ Gio.DBusCallFlags.NONE,
+ -1,
+ None,
+ )
+ .get_child_value(0)
+ )
+
+
+def name_running(name: str) -> bool:
+ return (
+ Gio.bus_get_sync(Gio.BusType.SESSION)
+ .call_sync(
+ "org.freedesktop.DBus",
+ "/org/freedesktop/DBus",
+ "org.freedesktop.DBus",
+ "NameHasOwner",
+ GLib.Variant("(s)", (name,)),
+ GLib.VariantType("(b)"), # type: ignore
+ Gio.DBusCallFlags.NONE,
+ -1,
+ None,
+ )
+ .get_child_value(0)
+ .get_boolean()
+ )
+
+
+def get_instance_proxy(iface_name: str) -> Gio.DBusProxy:
+ return Gio.DBusProxy.new_for_bus_sync(
+ Gio.BusType.SESSION,
+ Gio.DBusProxyFlags.NONE,
+ None,
+ iface_name,
+ FABRIC_DBUS_OBJECT_PATH,
+ FABRIC_DBUS_INTERFACE_NAME,
+ None,
+ )
+
+
+def check_and_get_instance_proxy(config_name: str, json: bool = False) -> Gio.DBusProxy:
+ if config_name.startswith(FABRIC_DBUS_INTERFACE_NAME):
+ iface_name = config_name
+ else:
+ iface_name = FABRIC_DBUS_INTERFACE_NAME + (
+ f".{config_name}" if config_name else ""
+ )
+ if not name_running(iface_name):
+ message = f"couldn't find a running Fabric instance with the name {config_name}"
+ if not json:
+ click.echo(message)
+ else:
+ click.echo({"error": message})
+ exit(1)
+ return get_instance_proxy(iface_name)
+
+
+def command(
+ name: str,
+ help: str = "",
+ needs_instance: bool = True,
+ with_json: bool = True,
+ *extra,
+):
+ def decorator(func) -> click.Command:
+ if with_json:
+ command = click.command(name=name, help=help)(
+ lambda *args, json=False, **kwargs: (
+ rval := func(*args, json=json, **kwargs),
+ click.echo(serialize_json(rval) if json else rval)
+ if rval
+ else None,
+ )
+ )
+ else:
+ command = click.command(name=name, help=help)(func)
+
+ if needs_instance:
+ command = click.argument("instance")(command)
+
+ for ext in extra:
+ command = ext(command) # type: ignore
+
+ if with_json:
+ command = click.option(
+ "--json", "-j", is_flag=True, help="to return the output in json format"
+ )(command)
+
+ return command
+
+ return decorator
+
+
+@command(
+ name="list-all",
+ help="list all currently running fabric instances",
+ needs_instance=False,
+)
+def list_all(json: bool = False):
+ filtered_names = filter(
+ lambda x: x.startswith(FABRIC_DBUS_INTERFACE_NAME), # type: ignore
+ get_dbus_names(), # type: ignore
+ )
+ if json:
+ return {"instances-dbus-names": list(filtered_names)}
+ for dbus_name in filtered_names:
+ config_name: str = dbus_name.removeprefix(FABRIC_DBUS_INTERFACE_NAME + ".")
+ proxy = get_instance_proxy(dbus_name)
+ click.echo(
+ f"{config_name}: {str(proxy.get_cached_property('File').unpack())}"
+ ) if proxy is not None else None
+ return
+
+
+@command(
+ name="list-actions",
+ help="list actions in a currently running fabric instance",
+ needs_instance=True,
+)
+def list_actions(instance: str, json: bool = False):
+ bus_object = check_and_get_instance_proxy(instance, json)
+ actions: dict[str, list[str]] = dict(bus_object.get_cached_property("Actions")) # type: ignore
+
+ if json:
+ return actions
+
+ for name, args in actions.items():
+ click.echo(f"{name} ({', '.join(args)})")
+ return
+
+
+@command(
+ "invoke-action",
+ "invoke an action within a running fabric instance",
+ True,
+ True,
+ click.argument("action-name"),
+ click.argument("arguments", nargs=-1),
+)
+def invoke_action(
+ instance: str, action_name: str, arguments: tuple[str, ...], json: bool = False
+):
+ bus_object = check_and_get_instance_proxy(instance, json)
+ err, msg = bus_object.InvokeAction("(sas)", action_name, arguments)
+
+ if json:
+ return {"error": err, "message": msg}
+
+ if err:
+ return f"couldn't invoke action\nerror: {msg}"
+
+ return f"action invoked\nreturn message: {msg}"
+
+
+@command(
+ "execute",
+ "executes a python code within the running fabric instance",
+ True,
+ True,
+ click.argument("source"),
+)
+def execute(instance: str, source: str, json: bool = False):
+ bus_object = check_and_get_instance_proxy(instance, json)
+ exc = bus_object.Execute("(s)", source)
+ if json:
+ return {"source": source, "exception": exc}
+ if exc:
+ return f"exception: {exc}"
+ return
+
+
+@command(
+ "evaluate",
+ "evaluate a python code within a running fabric instance and return the result",
+ True,
+ True,
+ click.argument("code"),
+)
+def evaluate(instance: str, code: str, json: bool = False):
+ bus_object = check_and_get_instance_proxy(instance, json)
+ result, exc = bus_object.Evaluate("(s)", code)
+
+ if json:
+ return {"code": code, "result": result, "exception": exc}
+
+ return "result: " + (result + "\nexception: " + exc if exc != "" else result)
+
+
+@click.group()
+def main():
+ pass
+
+
+if __name__ == "__main__":
+ main.add_command(list_all)
+ main.add_command(list_actions)
+ main.add_command(execute)
+ main.add_command(evaluate)
+ main.add_command(invoke_action)
+
+ main()
diff --git a/fabric/fabric/audio/__init__.py b/fabric/fabric/audio/__init__.py
new file mode 100644
index 0000000..4424044
--- /dev/null
+++ b/fabric/fabric/audio/__init__.py
@@ -0,0 +1 @@
+from .service import Audio, AudioStream
diff --git a/fabric/fabric/audio/service.py b/fabric/fabric/audio/service.py
new file mode 100644
index 0000000..5e2317e
--- /dev/null
+++ b/fabric/fabric/audio/service.py
@@ -0,0 +1,344 @@
+import gi
+from loguru import logger
+from typing import Literal
+from fabric.core.service import Service, Signal, Property
+from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case
+
+
+class CvcImportError(ImportError):
+ def __init__(self, *args):
+ super().__init__(
+ "Cvc is not installed, please install it first, you can use automated installer in the git repository",
+ *args,
+ )
+
+
+try:
+ gi.require_version("Cvc", "1.0")
+ from gi.repository import Cvc
+except Exception:
+ raise CvcImportError()
+
+
+class AudioStream(Service):
+ @Signal
+ def changed(self) -> None: ...
+
+ @Signal
+ def closed(self) -> None: ...
+
+ # TODO: implement a interface for getting the icon as a Gdk.Pixbuf (from stream.get_gicon())
+ @Property(str, "readable")
+ def icon_name(self) -> str:
+ return self._stream.get_icon_name()
+
+ @Property(int, "readable")
+ def id(self) -> int:
+ return self._stream.get_id()
+
+ @Property(str, "readable")
+ def name(self) -> str:
+ return self._stream.get_name()
+
+ @Property(str, "readable")
+ def description(self) -> str:
+ return self._stream.get_description()
+
+ @Property(str, "readable")
+ def application_id(self) -> str:
+ return self._stream.get_application_id()
+
+ @Property(str, "readable")
+ def state(self) -> str:
+ return snake_case_to_kebab_case(
+ get_enum_member_name(self._stream.get_state(), default="unknown")
+ )
+
+ @Property(str, "readable")
+ def control_state(self) -> str:
+ return snake_case_to_kebab_case(
+ get_enum_member_name(self._control.get_state(), default="unknown")
+ )
+
+ @Property(Cvc.MixerStream, "readable")
+ def stream(self) -> Cvc.MixerStream:
+ return self._stream
+
+ @Property(float, "read-write")
+ def volume(self) -> float:
+ return float(
+ (self._stream.get_volume() / self._control.get_vol_max_norm()) * 100
+ )
+
+ @volume.setter
+ def volume(self, value: float):
+ value = 0 if value < 0 else value
+ value = self._parent.max_volume if value > self._parent.max_volume else value
+ self._old_vol = self._stream.get_volume()
+ self._stream.set_volume(int((value * self._control.get_vol_max_norm()) / 100))
+ self._stream.push_volume() # type: ignore
+ return self.changed()
+
+ @Property(bool, "read-write", "is-muted", default_value=False)
+ def muted(self) -> bool:
+ return self._stream.get_is_muted()
+
+ @muted.setter
+ def muted(self, value: bool):
+ self._stream.set_is_muted(value)
+ self._stream.change_is_muted(value) # type: ignore
+ return self.changed()
+
+ @Property(str, "readable")
+ def type(self) -> str:
+ return Audio.get_stream_type(self.stream, default="unknown") # type: ignore
+
+ def __init__(
+ self,
+ stream: Cvc.MixerStream,
+ control: Cvc.MixerControl,
+ parent: "Audio",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self._old_vol = 0
+ self._stream = stream
+ self._control = control
+ self._parent = parent
+ for sn in [
+ "application-id",
+ "description",
+ "icon-name",
+ "is-muted",
+ "volume",
+ "state",
+ "id",
+ ]: # A PYRAMID
+ self._stream.connect(
+ f"notify::{sn}", lambda *args, sn=sn: self.notifier(sn, args)
+ )
+ # ^^^^^ python bug, i guess.
+
+ def notifier(self, name: str, *args):
+ self.notify(name)
+ self.changed()
+ return
+
+ def close(self):
+ return self.closed()
+
+ def __del__(self):
+ # hacking into the guts of python's garbage collector
+ return self.close()
+
+
+# TODO: cleanup a little bit more...
+class Audio(Service):
+ @Signal
+ def changed(self) -> None: ...
+ @Signal
+ def speaker_changed(self) -> None: ...
+ @Signal
+ def microphone_changed(self) -> None: ...
+ @Signal
+ def stream_added(self) -> None: ...
+ @Signal
+ def stream_removed(self) -> None: ...
+
+ @Property(AudioStream, "readable")
+ def speaker(self) -> AudioStream:
+ return self._speaker
+
+ @Property(list[AudioStream], "readable")
+ def speakers(self) -> list[AudioStream]:
+ return self.do_list_stream_type(Cvc.MixerSink)
+
+ @Property(AudioStream, "readable")
+ def microphone(self) -> AudioStream:
+ return self._microphone
+
+ @Property(list[AudioStream], "readable")
+ def microphones(self) -> list[AudioStream]:
+ return self.do_list_stream_type(Cvc.MixerSource)
+
+ @Property(list[AudioStream], "readable")
+ def applications(self) -> list[AudioStream]:
+ return self.do_list_stream_type(Cvc.MixerSinkInput)
+
+ @Property(list[AudioStream], "readable")
+ def recorders(self) -> list[AudioStream]:
+ return self.do_list_stream_type(Cvc.MixerSourceOutput)
+
+ @Property(int, "read-write")
+ def max_volume(self) -> int:
+ return self._max_volume
+
+ @max_volume.setter
+ def max_volume(self, value: int):
+ self._max_volume = value
+ return
+
+ @Property(str, "readable")
+ def state(self) -> str:
+ return snake_case_to_kebab_case(
+ get_enum_member_name(self._control.get_state(), default="unknown")
+ )
+
+ def __init__(
+ self,
+ max_volume: int = 100,
+ controller_name: str = "fabric audio control",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self._control = Cvc.MixerControl(name=controller_name)
+ self._max_volume = max_volume
+
+ self._streams: dict[int, AudioStream] = {}
+ self._stream_connectors: dict[int, int] = {}
+
+ self._speaker: AudioStream | None = None
+ self._speaker_connection: int | None = None
+
+ self._microphone: AudioStream | None = None
+ self._microphone_connection: int | None = None
+
+ bulk_connect(
+ self._control,
+ {
+ "stream-added": self.on_stream_added,
+ "stream-removed": self.on_stream_removed,
+ "default-sink-changed": lambda _, id: self.on_default_stream_changed(
+ id, "speaker"
+ ),
+ "default-source-changed": lambda _, id: self.on_default_stream_changed(
+ id, "microphone"
+ ),
+ },
+ )
+
+ # all aboard...
+ self._control.open()
+
+ def do_list_stream_type(
+ self,
+ stream_type: Literal[
+ "source",
+ "source-output",
+ "sink",
+ "sink-input",
+ ]
+ | type[Cvc.MixerSource]
+ | type[Cvc.MixerSourceOutput]
+ | type[Cvc.MixerSink]
+ | type[Cvc.MixerSinkInput]
+ | None = None,
+ ) -> list[AudioStream]:
+ if not stream_type:
+ return list(self._streams.values())
+
+ # get_enum_member won't help here :P
+ stream_type = (
+ {
+ "source": Cvc.MixerSource,
+ "source-output": Cvc.MixerSourceOutput,
+ "sink": Cvc.MixerSink,
+ "sink-input": Cvc.MixerSinkInput,
+ }.get(stream_type)
+ if isinstance(stream_type, str)
+ else stream_type
+ )
+
+ rlist = []
+ for _, strm in self._streams.items():
+ if not stream_type or not isinstance(strm.stream, stream_type):
+ continue
+ rlist.append(strm)
+ return rlist
+
+ def on_default_stream_changed(self, id: int, type: str):
+ logger.info(f"[Audio][{type.title()}] Changing default {type} to {id}")
+
+ if (old_strm := self.__getattribute__(f"_{type}")) is not None:
+ logger.info(f"[Audio][{type.title()}] Removing old {type} stream")
+ try:
+ hndlr_id = self.__getattribute__(f"_{type}_connection")
+ old_strm.handler_disconnect(hndlr_id)
+ except Exception as e:
+ logger.warning(
+ f"[Audio][{type.title()}] tried to remove a stream of type {type} but failed because of error ({e}), skipping..."
+ )
+
+ strm = self._streams.get(id)
+ if not strm:
+ return
+
+ self.__setattr__(f"_{type}", strm)
+ self.__setattr__(
+ f"_{type}_connection",
+ strm.connect("changed", lambda *args: self.emit(f"{type}-changed")),
+ )
+
+ self.emit(f"{type}-changed")
+ self.notify(type)
+
+ return self.changed()
+
+ def on_stream_added(self, _, stream_id: int):
+ stream = (
+ self._control.lookup_stream_id(stream_id)
+ or self._control.lookup_output_id(stream_id)
+ or self._control.lookup_input_id(stream_id)
+ )
+
+ if (
+ self._streams.get(stream_id) is not None
+ or not Audio.get_stream_type(stream, None) # type: ignore
+ or not stream
+ ):
+ return # No.
+
+ audio_stream = AudioStream(stream, self._control, self)
+ self._streams[stream_id] = audio_stream
+ self._stream_connectors[stream_id] = audio_stream.connect(
+ "changed", lambda *_: self.changed()
+ )
+
+ logger.info(
+ f"[Audio][{audio_stream.type.title()}] Adding stream {stream_id} with name {audio_stream.name}"
+ )
+ self.do_notify_streams(stream)
+ return self.changed()
+
+ def on_stream_removed(self, _, stream_id: int):
+ if not self._streams.get(stream_id):
+ return
+
+ audio_stream = self._streams.pop(stream_id)
+ audio_stream.handler_disconnect(self._stream_connectors.pop(stream_id)) # type: ignore
+
+ logger.info(
+ f"[Audio][{audio_stream.type.title()}] Removing stream {stream_id} with name {audio_stream.name}"
+ )
+ self.do_notify_streams(audio_stream.stream)
+ audio_stream.close()
+ return self.changed()
+
+ def do_notify_streams(self, stream: Cvc.MixerStream):
+ stream_type = Audio.get_stream_type(stream)
+ if not stream_type:
+ return
+ return self.notify(stream_type)
+
+ @staticmethod
+ def get_stream_type(
+ stream: Cvc.MixerStream,
+ default=None,
+ ) -> str | None:
+ # intended for internal usage only
+ return {
+ Cvc.MixerSink: "speakers",
+ Cvc.MixerSinkInput: "applications",
+ Cvc.MixerSource: "microphones",
+ Cvc.MixerSourceOutput: "recorders",
+ }.get(type(stream), default) # type: ignore
diff --git a/fabric/fabric/bluetooth/__init__.py b/fabric/fabric/bluetooth/__init__.py
new file mode 100644
index 0000000..aae744e
--- /dev/null
+++ b/fabric/fabric/bluetooth/__init__.py
@@ -0,0 +1 @@
+from .service import BluetoothClient, BluetoothDevice
diff --git a/fabric/fabric/bluetooth/service.py b/fabric/fabric/bluetooth/service.py
new file mode 100644
index 0000000..106cbe8
--- /dev/null
+++ b/fabric/fabric/bluetooth/service.py
@@ -0,0 +1,332 @@
+import gi
+from loguru import logger
+from collections.abc import Callable
+from typing import ParamSpec, Concatenate, Any
+from fabric.core.service import Service, Signal, Property
+from fabric.utils.helpers import (
+ bulk_connect,
+ get_enum_member_name,
+ snake_case_to_kebab_case,
+ bridge_signal,
+)
+
+from gi.repository import Gio
+
+try:
+ gi.require_version("GnomeBluetooth", "3.0")
+ from gi.repository import GnomeBluetooth
+except Exception:
+ raise ImportError("gnome-bluetooth-3 is not installed, please install it first")
+
+
+P = ParamSpec("P")
+
+
+class BluetoothDevice(Service):
+ @Signal
+ def changed(self) -> None: ...
+
+ @Property(GnomeBluetooth.Device, "readable")
+ def device(self) -> GnomeBluetooth.Device:
+ return self._device
+
+ @Property(bool, "read-write", "is-connected", default_value=False)
+ def connected(self) -> bool:
+ return self._device.get_property("connected") # type: ignore
+
+ @connected.setter
+ def connected(self, value: bool):
+ self.connecting = value
+ return
+
+ @Property(bool, "read-write", "is-connecting", default_value=False)
+ def connecting(self) -> bool:
+ return self._connecting
+
+ @connecting.setter
+ def connecting(self, value: bool):
+ self._connecting = True
+
+ def request_callback(succeed: bool):
+ self._connecting = False
+ self.notifier("connected")
+ self.notifier("connecting")
+ return
+
+ return self.connect_device(value, request_callback)
+
+ @Property(bool, "readable", "is-closed", default_value=False)
+ def closed(self) -> bool:
+ return self._closed
+
+ @Property(bool, "read-write", "is-paired", default_value=False)
+ def paired(self) -> bool:
+ return self._device.get_property("paired") # type: ignore
+
+ @paired.setter
+ def paired(self, value: bool):
+ return self._device.set_property("paired", value) # type: ignore
+
+ @Property(bool, "readable", "is-trusted", default_value=False)
+ def trusted(self) -> bool:
+ return self._device.get_property("trusted") # type: ignore
+
+ @Property(str, "readable")
+ def address(self) -> str:
+ return self._device.get_property("address") # type: ignore
+
+ @Property(str, "readable")
+ def name(self) -> str:
+ return self._device.get_property("name") # type: ignore
+
+ @Property(str, "readable")
+ def alias(self) -> str:
+ return self._device.get_property("alias") # type: ignore
+
+ @Property(str, "readable")
+ def icon_name(self) -> str:
+ return self._device.get_property("icon") # type: ignore
+
+ @Property(str, "readable")
+ def type(self) -> str:
+ return GnomeBluetooth.type_to_string(self._device.get_property("type")) # type: ignore
+
+ @Property(int, "readable")
+ def battery_level(self) -> int:
+ return self._device.get_property("battery-level") # type: ignore
+
+ @Property(float, "readable")
+ def battery_percentage(self) -> float:
+ return self._device.get_property("battery-percentage") # type: ignore
+
+ def __init__(
+ self, device: GnomeBluetooth.Device, client: "BluetoothClient", **kwargs
+ ):
+ super().__init__(**kwargs)
+ self._device: GnomeBluetooth.Device = device
+ self._client: BluetoothClient = client
+ self._signal_connectors: list[int] = []
+
+ for pn in (
+ "battery-percentage",
+ "battery-level",
+ "connected",
+ "trusted",
+ "address",
+ "paired",
+ "alias",
+ "icon",
+ "name",
+ ):
+ self._device.connect(f"notify::{pn}", lambda *_: self.emit("changed"))
+ self._signal_connectors.append(
+ bridge_signal(
+ self._device,
+ pn,
+ self,
+ "icon-name" if pn not in ("icon",) else pn,
+ notify=True,
+ )
+ )
+
+ self._closed = self._connecting = False
+
+ def connect_device(
+ self,
+ connect: bool = True,
+ callback: Callable[Concatenate[bool, P], Any] | None = None,
+ *args: P.args,
+ **kwargs: P.kwargs,
+ ):
+ return self._client.connect_device(self, connect, callback, *args, **kwargs)
+
+ def close(self):
+ for id in self._signal_connectors:
+ try:
+ self._device.disconnect_handler(id) # type: ignore
+ except Exception:
+ pass
+ self._closed = True
+ self.notifier("closed")
+ return
+
+ def notifier(self, name: str, args=None):
+ self.notify(name)
+ self.emit("changed")
+ return
+
+
+class BluetoothClient(Service):
+ @Signal
+ def changed(self) -> None: ...
+
+ @Signal
+ def closed(self) -> None: ...
+
+ @Signal
+ def device_added(self, address: str) -> None: ...
+
+ @Signal
+ def device_removed(self, address: str) -> None: ...
+
+ @Property(list[BluetoothDevice], "readable")
+ def devices(self) -> list[BluetoothDevice]:
+ return list(self._devices.values())
+
+ @Property(list[BluetoothDevice], "readable")
+ def connected_devices(self) -> list[BluetoothDevice]:
+ return [dev for dev in self._devices.values() if dev.connected]
+
+ @Property(str, "readable")
+ def state(self) -> str:
+ return snake_case_to_kebab_case(
+ get_enum_member_name(
+ self._client.get_property("default-adapter-state"), # type: ignore
+ default="unknown",
+ )
+ )
+
+ @Property(bool, "read-write", default_value=False)
+ def scanning(self) -> bool:
+ return self._client.get_property("default-adapter-setup-mode") # type: ignore
+
+ @scanning.setter
+ def scanning(self, value: bool):
+ return self._client.set_property("default-adapter-setup-mode", value) # type: ignore
+
+ @Property(bool, "read-write", default_value=False)
+ def enabled(self) -> bool:
+ return self.state in ("on", "turning-on")
+
+ @enabled.setter
+ def enabled(self, value: bool):
+ self.powered = value
+ return
+
+ @Property(bool, "read-write", "is-powered", default_value=False)
+ def powered(self) -> bool:
+ return self._client.get_property("default-adapter-powered") # type: ignore
+
+ @powered.setter
+ def powered(self, value: bool):
+ return self._client.set_property("default-adapter-powered", value) # type: ignore
+
+ @Property(str, "readable")
+ def address(self) -> str:
+ return self._client.get_property("default-adapter-address")
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._client: GnomeBluetooth.Client = GnomeBluetooth.Client.new() # type: ignore
+ self._devices: dict[str, BluetoothDevice] = {}
+
+ bulk_connect(
+ self._client,
+ {
+ "device-added": self.on_device_added,
+ "device-removed": self.on_device_removed,
+ "notify::default-adapter-state": lambda *args: self.notifier("state"),
+ "notify::default-adapter-powered": lambda *args: self.notifier(
+ "enabled"
+ ),
+ "notify::default-adapter-setup-mode": lambda *args: self.notifier(
+ "scanning"
+ ),
+ "notify::default-adapter-address": lambda *args: self.notifier(
+ "address"
+ ),
+ },
+ )
+ for device in self.do_get_raw_devices():
+ self.on_device_added(self._client, device) # type: ignore
+
+ def scan(self):
+ self.scanning = True
+ return
+
+ def toggle_power(self):
+ self.powered = not self.powered
+ return
+
+ def toggle_scan(self):
+ self.scanning = not self.scanning
+ return
+
+ def do_get_raw_devices(self) -> list[GnomeBluetooth.Device]:
+ all_devs: list[GnomeBluetooth.Device] = self._client.get_devices() # type: ignore
+ return [
+ dev
+ for dev in all_devs
+ if dev.get_paired() or dev.get_trusted() # type: ignore
+ ]
+
+ def on_device_added(self, _, device: GnomeBluetooth.Device):
+ addr: str = device.props.address # type: ignore
+ if self._devices.get(addr, None):
+ return
+
+ # this may be a mistake, it fixed an issue with me but might not be the right choice
+ # we should probably let the user choose what to do with devices with no name
+
+ # RE: it's a mistake, users are responsible about their devices
+
+ # if device.props.name is None:
+ # return
+ logger.info(f"[Bluetooth] Adding device: {addr}")
+
+ bluetooth_device: BluetoothDevice = BluetoothDevice(
+ device,
+ self,
+ notify_connected=lambda *_: self.notifier("connected-devices"),
+ on_changed=lambda *_: self.emit("changed"),
+ )
+
+ self._devices[addr] = bluetooth_device
+ self.emit("device-added", addr)
+ self.notifier("devices")
+
+ def on_device_removed(self, _, object_path: str):
+ addr = object_path.split("/")[-1][4:].replace("_", ":")
+ if not (device := self._devices.pop(addr, None)):
+ return logger.warning(
+ f"[Bluetooth] tried to remove a unknown device with the address {addr}"
+ )
+
+ logger.info(f"[Bluetooth] Removing device: {addr}")
+
+ self.emit("device-removed", addr)
+ if device.connected:
+ self.notifier("connected-devices")
+ self.notifier("devices")
+ return device.close()
+
+ def get_device(self, address: str) -> BluetoothDevice | None:
+ return self._devices.get(address, None)
+
+ def connect_device(
+ self,
+ device: BluetoothDevice,
+ connect: bool = True,
+ callback: Callable[Concatenate[bool, P], Any] | None = None,
+ *args: P.args,
+ **kwargs: P.kwargs,
+ ):
+ def inner_callback(client: GnomeBluetooth.Client, res: Gio.AsyncResult):
+ if not callback:
+ return
+ try:
+ finish = client.connect_service_finish(res)
+ logger.info(f"[Bluetooth] Connected to device {device.address}")
+ callback(finish, *args, **kwargs)
+ except Exception:
+ logger.warning(f"[Bluetooth] Failed to connect device {device.address}")
+ callback(False, *args, **kwargs)
+
+ return self._client.connect_service(
+ device.device.get_object_path(), connect, None, inner_callback
+ )
+
+ def notifier(self, name: str, *args):
+ self.notify(name)
+ self.emit("changed")
+ return
diff --git a/fabric/fabric/core/__init__.py b/fabric/fabric/core/__init__.py
new file mode 100644
index 0000000..42e0434
--- /dev/null
+++ b/fabric/fabric/core/__init__.py
@@ -0,0 +1,5 @@
+from .service import Service, Signal, Property, Builder
+from .application import Application
+from .fabricator import Fabricator
+
+__all__ = ["Service", "Signal", "Property", "Builder", "Application", "Fabricator"]
diff --git a/fabric/fabric/core/application.py b/fabric/fabric/core/application.py
new file mode 100644
index 0000000..6662d96
--- /dev/null
+++ b/fabric/fabric/core/application.py
@@ -0,0 +1,538 @@
+import gi
+import re
+import os
+import atexit
+import inspect
+from loguru import logger
+from types import FrameType
+from inspect import FrameInfo
+from typing import overload, Literal, Self
+from collections.abc import Iterable, Callable
+from fabric.core.service import Service, Property
+from fabric.utils.helpers import (
+ load_dbus_xml,
+ snake_case_to_kebab_case,
+ get_function_annotations,
+ compile_css,
+)
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk, GLib, Gio
+
+
+FABRIC_DBUS_INTERFACE_NAME = "org.Fabric.fabric"
+FABRIC_DBUS_OBJECT_PATH = "/org/Fabric/fabric"
+FABRIC_DBUS_INTERFACE_NODES = load_dbus_xml(
+ f"../dbus_assets/{FABRIC_DBUS_INTERFACE_NAME}.xml"
+)
+
+
+class FileHook:
+ file_path: str
+ frame: FrameType
+ frame_info: FrameInfo
+ global_scope: dict
+ local_scope: dict
+
+ @classmethod
+ def from_here(cls, depth: int = -1) -> Self:
+ instance = cls.__new__(cls)
+ instance.__init__(inspect.stack()[depth])
+ return instance
+
+ def __init__(self, frame_info: FrameInfo, **kwargs):
+ super().__init__(**kwargs)
+ self.frame_info: FrameInfo = frame_info
+ self.frame: FrameType = frame_info.frame
+ self.global_scope: dict = self.frame.f_globals if self.frame is not None else {}
+ self.local_scope = self.frame.f_locals if self.frame is not None else {}
+ self.file_path: str = (
+ (
+ frame_info.filename
+ if frame_info is not None and frame_info.filename is not None
+ else None
+ )
+ or (
+ self.global_scope.get("__file__", "unknown")
+ if self.global_scope is not None
+ else None
+ )
+ or "unknown"
+ )
+
+ def execute(
+ self, source: str, raise_on_exception: bool = False
+ ) -> Exception | None:
+ error = None
+ try:
+ exec(source, self.global_scope, self.local_scope)
+ except Exception as e:
+ error = e
+ if raise_on_exception is True and error not in (None, ""):
+ raise error
+ return error
+
+ def evaluate(self, code: str, raise_on_exception: bool = False):
+ result = None
+ error = None
+ try:
+ result = eval(
+ code,
+ self.global_scope,
+ self.local_scope,
+ )
+ except Exception as e:
+ error = e
+ if raise_on_exception is True and error not in (None, ""):
+ raise error
+ return result, error
+
+ def is_class(self, class_name: str | object):
+ return inspect.isclass(self.global_scope.get(class_name, None))
+
+
+class DBusClient:
+ def __init__(
+ self,
+ application: "Application",
+ connection: Gio.DBusConnection,
+ *args,
+ **kwargs,
+ ):
+ super().__init__(*args, **kwargs)
+ self.application = application
+ self.hook = FileHook.from_here()
+ self.connection: Gio.DBusConnection = connection
+ self.do_register()
+
+ def do_register(self):
+ for interface in FABRIC_DBUS_INTERFACE_NODES.interfaces:
+ if interface.name == FABRIC_DBUS_INTERFACE_NAME:
+ self.connection.register_object(
+ FABRIC_DBUS_OBJECT_PATH,
+ interface,
+ self.do_handle_bus_call, # type: ignore
+ )
+ return self.connection.flush()
+
+ def do_handle_bus_call(
+ self,
+ conn: Gio.DBusConnection,
+ sender: str,
+ path: str,
+ interface: str,
+ target: str,
+ params: tuple,
+ invocation: Gio.DBusMethodInvocation,
+ user_data: object = None,
+ ) -> None:
+ # property accessing
+ match target:
+ case "Get":
+ prop_name = params[1] if len(params) >= 1 else None
+ match prop_name:
+ case "File":
+ invocation.return_value(
+ GLib.Variant(
+ "(v)", (GLib.Variant("s", self.hook.file_path),)
+ )
+ )
+ case "Windows":
+ invocation.return_value(
+ GLib.Variant(
+ "(v)",
+ (GLib.Variant("a{sb}", self.do_serialize_windows()),),
+ )
+ )
+ case "Actions":
+ invocation.return_value(
+ GLib.Variant(
+ "(v)",
+ (GLib.Variant("a{sas}", self.do_serialize_actions()),),
+ )
+ )
+ case _:
+ invocation.return_value(None)
+
+ case "GetAll":
+ all_properties = {
+ "File": GLib.Variant("s", self.hook.file_path),
+ "Windows": GLib.Variant("a{sb}", self.do_serialize_windows()),
+ "Actions": GLib.Variant("a{sas}", self.do_serialize_actions()),
+ }
+
+ invocation.return_value(GLib.Variant("(a{sv})", (all_properties,)))
+
+ # interface methods
+ case "Log":
+ level, message = params
+ match level:
+ case 0:
+ logger.debug(message)
+ case 1:
+ logger.info(message)
+ case 2:
+ logger.warning(message)
+ case 3:
+ logger.error(message)
+ invocation.return_value(None)
+ case "Execute":
+ source: str = params[0]
+ exc = self.hook.execute(source)
+ invocation.return_value(
+ GLib.Variant("(s)", (exc.__repr__() if exc is not None else "",))
+ )
+ case "Evaluate":
+ code: str = params[0]
+ result, exc = self.hook.evaluate(code)
+ invocation.return_value(
+ GLib.Variant(
+ "(ss)",
+ (
+ str(result),
+ exc.__repr__() if exc is not None else "",
+ ),
+ )
+ )
+ case "InvokeAction":
+ action_name: str = params[0]
+ arguments: list[str] = params[1]
+ if not (action := self.application.actions.get(action_name, None)):
+ invocation.return_value(
+ GLib.Variant(
+ "(bs)",
+ (True, f"couldn't find action with name `{action_name}`"),
+ )
+ )
+ else:
+ try:
+ invocation.return_value(
+ GLib.Variant("(bs)", (False, str(action[0](*arguments))))
+ )
+ except Exception as e:
+ invocation.return_value(
+ GLib.Variant("(bs)", (False, e.__repr__()))
+ )
+ case _:
+ invocation.return_value(None)
+
+ return conn.flush()
+
+ def do_serialize_windows(self) -> dict[str, bool]:
+ windows: dict[str, bool] = {}
+ for window in self.application.windows:
+ windows[window.get_name()] = window.is_visible()
+ return windows
+
+ def do_serialize_actions(self) -> dict[str, tuple[str, ...]]:
+ rd: dict[str, tuple[str, ...]] = {}
+ for name, value in self.application.actions.items():
+ rd[name] = value[1]
+ return rd
+
+
+class Application(Gtk.Application, Service):
+ _actions: dict[str, tuple[Callable, tuple[str, ...]]] = {}
+
+ activated = Property(bool, flags="read-write", default_value=False)
+ name = Property(str, flags="read-write")
+
+ @Property(dict[str, tuple[Callable, tuple[str, ...]]])
+ def actions(self) -> dict[str, tuple[Callable, tuple[str, ...]]]:
+ return self._actions
+
+ @Property(list[Gtk.Window])
+ def windows(self) -> list[Gtk.Window]:
+ return self._windows
+
+ @windows.setter
+ def windows(self, windows: list[Gtk.Window]):
+ for window in self._windows:
+ self.remove_window(window)
+ return self._windows.extend(windows)
+
+ @Property(list[Gtk.StyleProvider], "readable")
+ def style_providers(self) -> list[Gtk.StyleProvider]:
+ return self._style_providers
+
+ @overload
+ def __init__(
+ self,
+ name: str = "default",
+ *windows: Gtk.Window,
+ open_client: bool = True,
+ open_inspector: bool = False,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ *windows: Gtk.Window,
+ open_client: bool = True,
+ open_inspector: bool = False,
+ **kwargs,
+ ): ...
+
+ def __init__(
+ self,
+ *args: str | Gtk.Window,
+ open_client: bool = True,
+ open_inspector: bool = False,
+ **kwargs,
+ ):
+ name: str
+ windows: Iterable[Gtk.Window]
+
+ name = "default"
+ if len(args) < 1:
+ windows = ()
+ elif isinstance(args[0], str):
+ name, *windows = args # type: ignore
+ elif isinstance(args[0], Gtk.Window):
+ windows = args # type: ignore
+ else:
+ raise ValueError # FIXME: add a error message
+
+ application_id = FABRIC_DBUS_INTERFACE_NAME + f".{name or 'default'}"
+
+ if not self.validate_name(name):
+ raise ValueError(
+ f"`{name}` is an invalid name, consider using only English letters, numbers, and hyphens in the name"
+ )
+
+ if self.name_running(application_id):
+ raise ValueError(
+ f"there's already a Fabric application instance running with the name `{name}`"
+ )
+
+ Gtk.Application.__init__(
+ self, # type: ignore
+ application_id=application_id,
+ )
+
+ Service.__init__(
+ self,
+ **kwargs,
+ )
+ self._windows: list[Gtk.Window] = []
+ self._open_client = open_client
+ self._style_providers = []
+
+ self.name = name
+ self.windows = [window for window in windows]
+ GLib.set_application_name(name) if name else None
+
+ self.open_inspector() if open_inspector else None
+
+ def remove_window(self, window: Gtk.Window):
+ self._windows.remove(window)
+ if self.activated:
+ super().remove_window(window)
+ return
+
+ def add_window(self, window: Gtk.Window):
+ self._windows.append(window)
+ if self.activated:
+ super().add_window(window)
+ return
+
+ def dispatch_windows(self):
+ if not self.activated:
+ return
+ for window in self.windows:
+ super().add_window(window)
+ return
+
+ def do_activate(self):
+ if self.activated:
+ return
+ self.activated = True
+ self.hold()
+ self.dispatch_windows()
+
+ if self._open_client:
+ logger.debug(
+ f"[Fabric] opening a DBus client for Application with name {self.name}"
+ )
+ self.dbus_client = DBusClient(self, self.get_dbus_connection())
+ else:
+ self.dbus_client = None
+
+ def quit(self, *_):
+ self.activated = False
+ return super().quit()
+
+ def run(self, *args, **kwargs):
+ def on_exit():
+ # assuming clean exit (e.g. segfaults are considered dirty)
+ return logger.info("[Fabric] exiting...")
+
+ atexit.register(on_exit)
+ try:
+ super().run(*args, **kwargs)
+ finally:
+ # the application has quit (somehow in a bad way...)
+ # we do nothing, just return thus print the at exit message
+ return
+
+ def open_inspector(self):
+ return Gtk.Window.set_interactive_debugging(True) # type: ignore
+
+ @staticmethod
+ def validate_name(name: str) -> bool:
+ pattern = re.compile(r"^[A-Za-z0-9-]+$")
+ return bool(pattern.match(name))
+
+ @staticmethod
+ def name_running(name: str) -> bool:
+ return (
+ Gio.bus_get_sync(Gio.BusType.SESSION)
+ .call_sync(
+ "org.freedesktop.DBus",
+ "/org/freedesktop/DBus",
+ "org.freedesktop.DBus",
+ "NameHasOwner",
+ GLib.Variant("(s)", (name,)),
+ GLib.VariantType("(b)"), # type: ignore
+ Gio.DBusCallFlags.NONE,
+ -1,
+ None,
+ )
+ .get_child_value(0)
+ .get_boolean()
+ )
+
+ @staticmethod
+ def get_dbus_proxy(config_name: str | None = None) -> Gio.DBusProxy | None:
+ proxy = Gio.DBusProxy.new_for_bus_sync(
+ Gio.BusType.SESSION,
+ Gio.DBusProxyFlags.NONE,
+ None,
+ FABRIC_DBUS_INTERFACE_NAME
+ + (f".{config_name}" if config_name is not None else ""),
+ FABRIC_DBUS_OBJECT_PATH,
+ FABRIC_DBUS_INTERFACE_NAME,
+ None,
+ )
+ return proxy
+
+ def add_style_provider(
+ self,
+ provider: Gtk.StyleProvider,
+ priority: Literal["fallback", "theme", "settings", "application", "user"]
+ | int = Gtk.STYLE_PROVIDER_PRIORITY_USER,
+ ) -> None:
+ # well, i have to manually bind this
+ self._style_providers.append(provider)
+ self.notify("style-providers")
+ return Gtk.StyleContext.add_provider_for_screen( # type: ignore
+ Gdk.Screen.get_default(),
+ provider,
+ (
+ {
+ "fallback": Gtk.STYLE_PROVIDER_PRIORITY_FALLBACK,
+ "theme": Gtk.STYLE_PROVIDER_PRIORITY_THEME,
+ "settings": Gtk.STYLE_PROVIDER_PRIORITY_SETTINGS,
+ "application": Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
+ "user": Gtk.STYLE_PROVIDER_PRIORITY_USER,
+ }.get(priority, Gtk.STYLE_PROVIDER_PRIORITY_USER)
+ if isinstance(priority, str)
+ else priority
+ ),
+ )
+
+ def remove_style_provider(self, provider: Gtk.StyleProvider) -> None:
+ screen = Gdk.Screen.get_default()
+ return Gtk.StyleContext.remove_provider_for_screen(screen, provider) # type: ignore
+
+ def reset_styles(self) -> None:
+ for style_provider in self.style_providers:
+ self.remove_style_provider(style_provider)
+ self._style_providers.remove(style_provider)
+ return
+
+ def set_style_provider(
+ self,
+ provider: Gtk.StyleProvider,
+ priority: Literal["fallback", "theme", "settings", "application", "user"]
+ | int = Gtk.STYLE_PROVIDER_PRIORITY_USER,
+ ) -> None:
+ self.reset_styles()
+ return self.add_style_provider(provider, priority)
+
+ def set_stylesheet_from_file(
+ self,
+ file_path: str,
+ compile: bool = True,
+ append: bool = False,
+ *args,
+ **kwargs,
+ ) -> None:
+ if compile:
+ with open(file_path, "r") as f:
+ self.set_stylesheet_from_string(
+ f.read(),
+ compile,
+ append,
+ base_path=os.path.dirname(file_path),
+ *args,
+ **kwargs,
+ )
+ return
+ provider = Gtk.CssProvider()
+ provider.load_from_path(file_path)
+
+ return (self.set_style_provider if not append else self.add_style_provider)(
+ provider, *args, **kwargs
+ )
+
+ def set_stylesheet_from_string(
+ self,
+ style_string: str,
+ compile: bool = True,
+ append: bool = False,
+ *args,
+ **kwargs,
+ ) -> None:
+ provider = Gtk.CssProvider()
+ provider.load_from_data(
+ bytearray(
+ compile_css(style_string, *args, **kwargs) if compile else style_string,
+ "utf-8",
+ ) # type: ignore
+ )
+
+ return (self.set_style_provider if not append else self.add_style_provider)(
+ provider
+ )
+
+ @staticmethod
+ def action(name: str | None = None):
+ """
+ An action is an alternative way for calling userspace defined functions through the dbus client (or cli)
+ actions make it easier for calling functions defined in modules rather than your config's entry point
+
+ :param name: a name to register this action with, if none, use the function's name converted to kebab case, defaults to None
+ :type name: str | None, optional
+ """
+
+ def wrapper(func: Callable):
+ nonlocal name
+
+ name = name or snake_case_to_kebab_case(func.__name__)
+ if oa := Application._actions.get(name, None):
+ logger.error(
+ f"[Action] a different action is already registered with the name `{name}` ({oa})"
+ )
+ return func
+
+ logger.info(f"[Action] registering action with name {name}")
+
+ Application._actions[name] = (
+ func,
+ tuple(get_function_annotations(func).arguments.keys()),
+ )
+ return func
+
+ return wrapper
diff --git a/fabric/fabric/core/fabricator.py b/fabric/fabric/core/fabricator.py
new file mode 100644
index 0000000..df51e66
--- /dev/null
+++ b/fabric/fabric/core/fabricator.py
@@ -0,0 +1,180 @@
+from loguru import logger
+from collections.abc import Callable
+from typing import (
+ cast,
+ overload,
+ Concatenate,
+ ParamSpec,
+ Generic,
+ TypeVar,
+ Self,
+ Any,
+)
+from fabric.core.service import Service, Signal, Property
+from fabric.utils.helpers import invoke_repeater, exec_shell_command_async, idle_add
+from gi.repository import GLib, Gio
+
+T = TypeVar("T")
+P = ParamSpec("P")
+MISSING = TypeVar("MISSING")
+
+
+# TODO: improve typing...
+class Fabricator(Service, Generic[T]):
+ @Signal
+ def changed(self, value: object) -> None: ...
+
+ @Signal
+ def polling_done(self, last_value: object) -> None: ...
+
+ @Property(type[T], flags="read-write")
+ def value(self) -> type[T]:
+ return self._value # type: ignore
+
+ @value.setter
+ def value(self, value):
+ self._value = value
+ return self.changed(value)
+
+ @overload
+ def __init__(
+ self,
+ poll_from: Callable[Concatenate[Self, P], T],
+ interval: int = 1000,
+ stream: bool = False,
+ default_value: T | Any = None,
+ initial_poll: bool = True,
+ *data,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ poll_from: Callable[Concatenate[Self, P], T],
+ interval: int = 1000,
+ stream: bool = True,
+ default_value: T | Any = None,
+ initial_poll: bool = True,
+ *data,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ poll_from: str,
+ interval: int = 1000,
+ stream: bool = False,
+ default_value: str | Any = None,
+ initial_poll: bool = True,
+ *args,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ poll_from: str,
+ interval: int = 1000,
+ stream: bool = True,
+ default_value: str | Any = None,
+ initial_poll: bool = True,
+ *args,
+ **kwargs,
+ ): ...
+
+ def __init__(
+ self,
+ poll_from: Callable[Concatenate[Self, P], T] | str,
+ interval: int = 1000,
+ stream: bool = False,
+ default_value: T | str | Any = None,
+ initial_poll: bool = True,
+ *data,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+
+ self._value = default_value
+ self._poll_from = poll_from
+ self._interval = interval
+ self._initial_poll = initial_poll
+ self._data = data
+ self._poll = True
+ self._stream = stream
+ self._stream_thread: GLib.Thread | None = None
+
+ self.start() if initial_poll is True else None
+
+ def start(self):
+ self._poll = True
+ is_callable = callable(self._poll_from)
+ if is_callable and not self._stream:
+ invoke_repeater(self._interval, self.do_invoke_function)
+
+ elif is_callable and self._stream:
+ self._stream_thread = GLib.Thread.new(
+ f"fabricator-thread-{self.__str__()}",
+ self.do_read_function_stream,
+ *self._data,
+ )
+
+ elif not is_callable and not self._stream:
+ invoke_repeater(self._interval, self.do_read_shell_command_io)
+
+ elif not is_callable and self._stream:
+ self.do_read_shell_command_io()
+
+ else:
+ self.emit("polling-done", None)
+ logger.warning(
+ "[Fabricator] polling mode is unknown, probably because of the passed arguments begin wrong, skipping..."
+ )
+
+ def stop(self) -> None:
+ if self._poll is True:
+ self._poll = False
+ if self._stream_thread:
+ self._stream_thread.exit()
+ return
+ return logger.warning(
+ f"[Fabricator] Fabricator ({self}) is already not polling"
+ )
+
+ def do_read_shell_command_io(self):
+ data = None
+
+ def result_handler(output: str):
+ nonlocal data
+ if not self._poll:
+ self.emit("polling-done", None)
+ return cast(Gio.Subprocess, process).force_exit() # sorry
+ data = output
+ self.value = data
+
+ process, _ = exec_shell_command_async(
+ cast(str, self._poll_from), result_handler
+ )
+ process.wait_async(
+ None, lambda *_: self.emit("polling-done", data)
+ ) if process and self._stream else None
+ return True
+
+ def do_invoke_function(self):
+ if self._poll is not True:
+ self.emit("polling-done", None)
+ return False
+ value = cast(Callable, self._poll_from)(self, *self._data)
+ self.value = value
+ return True
+
+ def do_read_function_stream(self, *func_data):
+ # this should get called in a new thread, else the main thread will be blocked
+ data = None
+ for data in cast(Callable, self._poll_from)(self, *func_data):
+ if not self._poll:
+ break
+ idle_add(self.set_value, data)
+ idle_add(self.emit, "polling-done", data)
+ return False
diff --git a/fabric/fabric/core/service.py b/fabric/fabric/core/service.py
new file mode 100644
index 0000000..6f02f56
--- /dev/null
+++ b/fabric/fabric/core/service.py
@@ -0,0 +1,604 @@
+# GObject must be imported first so the overrides can be imported
+from gi.repository import GObject
+
+# all aboard...
+import gi.overrides
+import gi._signalhelper
+import gi._propertyhelper
+import gi.overrides.GObject
+
+from dataclasses import dataclass
+from collections.abc import Callable
+from typing import (
+ overload,
+ Concatenate,
+ ParamSpec,
+ Generator,
+ Optional,
+ Generic,
+ TypeVar,
+ Literal,
+ Union,
+ Self,
+ Any,
+)
+from fabric.utils.helpers import (
+ get_enum_member,
+ snake_case_to_kebab_case,
+ kebab_case_to_snake_case,
+ get_function_annotations,
+ make_arguments_ignorable,
+)
+
+OldSignal = gi._signalhelper.Signal
+OldProperty = gi._propertyhelper.Property
+OldGObject = gi.overrides.GObject.Object
+
+old_signal_installer = gi._signalhelper.install_signals
+old_property_installer = gi._propertyhelper.install_properties
+
+
+P = ParamSpec("P")
+R = TypeVar("R")
+T = TypeVar("T")
+G = TypeVar("G", bound=GObject.Object)
+
+
+def override(parent: object, child_name: str):
+ def inner(obj: T) -> T:
+ setattr(parent, child_name, obj)
+ return obj
+
+ return inner
+
+
+# TODO: improve the structure of this class
+@override(GObject, "Property")
+@override(gi._propertyhelper, "Property")
+class Property(OldProperty, Generic[T]):
+ """
+ replace Python's `property` built-in decorator
+ with this decorator to get properties in your service
+ """
+
+ name: str
+ flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE
+ install: bool = True
+
+ def __init__(
+ self,
+ type: type[T],
+ flags: Union[
+ Literal[
+ "r",
+ "w",
+ "rw",
+ "readable",
+ "writable",
+ "read-write",
+ "construct",
+ "construct-only",
+ "lax-validation",
+ "static-name",
+ "private",
+ "static-nick",
+ "static-blurb",
+ "explicit-notify",
+ "deprecated",
+ ],
+ GObject.ParamFlags,
+ ] = GObject.ParamFlags.READWRITE,
+ nickname: Optional[str] = None,
+ description: str = "",
+ # boolean types
+ default_value: Optional[T] = None,
+ # int types
+ minimum: Optional[int] = None,
+ maximum: Optional[int] = None,
+ getter: Optional[Callable] = None,
+ setter: Optional[Callable] = None,
+ install: bool = True,
+ **kwargs,
+ ):
+ self.install = install
+ flags = get_enum_member(
+ GObject.ParamFlags,
+ flags,
+ {
+ "r": "readable",
+ "w": "writeable",
+ "rw": "readwrite",
+ "read-write": "readwrite",
+ },
+ )
+
+ if not self.install:
+ self.__setattr__(
+ "_setter_middle_gate",
+ lambda instance, value: self.fset(instance, value), # type: ignore
+ )
+ try:
+ _type = type if issubclass(type, (bool, int, float, str)) else object
+ except TypeError:
+ _type = object
+
+ super().__init__(
+ type=_type,
+ default=default_value,
+ nick=nickname or "",
+ blurb=description,
+ flags=flags,
+ getter=getter,
+ setter=setter,
+ minimum=minimum,
+ maximum=maximum,
+ **kwargs,
+ )
+
+ def _default_getter(self, instance) -> T:
+ return getattr(instance, ("_property_helper_" + self.name), self.default)
+
+ def _default_setter(self, instance, value: T) -> None:
+ return setattr(instance, ("_property_helper_" + self.name), value)
+
+ def getter(self, fget: Callable[..., T]) -> Self:
+ return super().getter(fget)
+
+ def setter(self, fset: Callable) -> Self:
+ return super().setter(fset)
+
+ def __call__(self, fget: Callable[..., T]) -> Self:
+ return self.getter(fget)
+
+ def __get__(self, instance, klass=None) -> T:
+ # Property -> get current value of self
+ return super().__get__(instance, klass) # type: ignore
+
+ def __set__(self, instance, value) -> None:
+ # Property = X -> set X as the current value of the property
+ return self._setter_middle_gate(instance, value)
+
+ def _setter_middle_gate(self, instance, value) -> None:
+ return super().__set__(instance, value)
+
+ @staticmethod
+ @override(gi._propertyhelper, "install_properties")
+ def installer(klass):
+ klass_dict: dict[str, Any] = klass.__dict__.copy()
+ klass_properties: dict[str, tuple] = klass_dict.get("__gproperties__", {})
+ scanned_properties: list[OldProperty] = []
+
+ for iklass in reversed(
+ klass.__mro__
+ ): # fix what pygobject missed, inheriting properties
+ for name, value in iklass.__dict__.copy().items():
+ if not isinstance(value, OldProperty):
+ continue
+
+ if isinstance(value, Property) and not value.install:
+ continue
+
+ if not value.name:
+ value.name = snake_case_to_kebab_case(name)
+
+ # we will encounter the same property multiple times in case of
+ # custom setter methods
+ if value.name in klass_properties:
+ if klass_properties[value.name] == value.get_pspec_args():
+ continue
+ raise ValueError(
+ f"Property {value.name} was already found in __gproperties__"
+ )
+ klass_properties[value.name] = value.get_pspec_args()
+ scanned_properties.append(value)
+
+ for prop in scanned_properties:
+ if prop.fget is prop._default_getter or prop.fset is prop._default_setter:
+ raise TypeError(
+ f"GObject subclass {klass.__name__} defines do_get/set_property"
+ + " and it also uses a property with a custom setter"
+ + " or getter. This is not allowed"
+ )
+
+ setattr(
+ klass,
+ f"get_{kebab_case_to_snake_case(prop.name)}", # type: ignore
+ lambda self, *args, _prop=prop, **kwargs: Service.get_property(
+ self,
+ _prop.name, # type: ignore
+ *args,
+ **kwargs,
+ ),
+ )
+ setattr(
+ klass,
+ f"set_{kebab_case_to_snake_case(prop.name)}", # type: ignore
+ lambda self, value, *args, _prop=prop, **kwargs: Service.set_property(
+ self,
+ _prop.name, # type: ignore
+ value,
+ *args,
+ **kwargs,
+ ),
+ )
+
+ # all aboard...
+ def property_do_get(self, pspec):
+ return getattr(self, kebab_case_to_snake_case(pspec.name), None)
+
+ def property_do_set(self, pspec, value):
+ prop = getattr(klass, kebab_case_to_snake_case(pspec.name), None)
+ prop.fset(self, value) if prop is not None else None
+
+ setattr(klass, "do_get_property", property_do_get)
+ setattr(klass, "do_set_property", property_do_set)
+ setattr(klass, "__gproperties__", klass_properties)
+
+
+@dataclass
+class SignalWrapper(Generic[G, P, R]):
+ name: str
+ instance: G
+ func: Optional[Callable[Concatenate[G, P], R]] = None
+
+ def __call__(self, /, *args: P.args, **kwargs: P.kwargs) -> Optional[R]:
+ return self.emit(*args, **kwargs)
+
+ def emit(self, /, *args: P.args, **kwargs: P.kwargs) -> Optional[R]:
+ rvalue: Optional[R] = (
+ self.func(self.instance, *args, **kwargs) if self.func is not None else None
+ )
+ self.instance.emit(self.name, *args, **kwargs) # type: ignore
+ return rvalue
+
+ # TODO: make it hint self's instance for the callback
+ def connect(
+ self,
+ callback: Callable[Concatenate[G, P], Any] | Callable,
+ *args,
+ ignore_missing: bool = True,
+ ) -> int:
+ return Service.connect(
+ self.instance, # type: ignore
+ self.name,
+ callback,
+ *args,
+ ignore_missing=ignore_missing,
+ )
+
+
+class Signal(Generic[G, P, R]):
+ name: str
+ flags: GObject.SignalFlags = GObject.SignalFlags.RUN_FIRST
+ arg_types: tuple = ()
+ rtype: Any = None
+ install: bool = True
+
+ # private, kinda
+ func: Optional[Callable[Concatenate[G, P], R]] = None
+
+ def __init__(
+ self,
+ name: Union[str, Callable[Concatenate[G, P], R]],
+ flags: Union[
+ Literal[
+ "run-last",
+ "run-first",
+ "run-cleanup",
+ "no-recurse",
+ "detailed",
+ "action",
+ "no-hooks",
+ "must-collect",
+ "deprecated",
+ "accumulator-first-run",
+ ],
+ GObject.SignalFlags,
+ ] = GObject.SignalFlags.RUN_FIRST,
+ rtype: Any = None,
+ arg_types: tuple = (),
+ install: bool = True,
+ **kwargs,
+ ) -> None:
+ super().__init__(**kwargs)
+ self.flags = get_enum_member(GObject.SignalFlags, flags)
+ self.install = install
+ if callable(name):
+ self.func = name
+ self.name = snake_case_to_kebab_case(self.func.__name__)
+ fanno = get_function_annotations(self.func)
+ self.arg_types, self.rtype = (
+ tuple(filter(lambda x: x is not Any, fanno[0].values())),
+ fanno[1],
+ )
+ return
+ self.name = name
+ self.arg_types, self.rtype = arg_types, rtype
+ return
+
+ def __call__(self, func: Callable[Concatenate[G, P], R]) -> "Signal[G, P, R]":
+ rsignal = Signal(func, self.flags, install=self.install)
+ rsignal.name = self.name or rsignal.name
+ return rsignal
+
+ def __get__(self, instance: G, owner: type[G]) -> SignalWrapper[G, P, R]:
+ # looks like we have an initialized object now...
+ return SignalWrapper(
+ self.name,
+ instance,
+ self.func,
+ )
+
+ def detail(self, detail_name) -> "Signal[G, P, R]":
+ # a decorator for creating detailed signals
+ if (GObject.SignalFlags.DETAILED & self.flags) != GObject.SignalFlags.DETAILED: # type: ignore
+ self.flags = self.flags | GObject.SignalFlags.DETAILED # type: ignore
+ rsignal = Signal(
+ self.name + "::" + detail_name,
+ self.flags,
+ self.arg_types,
+ self.rtype,
+ False,
+ )
+ rsignal.func = self.func
+ return rsignal
+
+ def serialize(
+ self,
+ ) -> dict[str, tuple[GObject.SignalFlags, Any, tuple[Any]]]:
+ if not self.name:
+ return {} # i don't even know
+ return {f"{self.name}": (self.flags, self.rtype, self.arg_types)}
+
+ @staticmethod
+ @override(gi._signalhelper, "install_signals")
+ def installer(klass):
+ klass_dict: dict[str, Any] = klass.__dict__.copy()
+ klass_signals: dict[str, tuple] = klass_dict.get("__gsignals__", {})
+ scanned_signals: list[Signal] = []
+ for name, value in klass_dict.items():
+ if isinstance(value, OldSignal):
+ # for compatibility with legacy modules
+ old_signal_installer(klass)
+ continue
+ if not isinstance(value, Signal) or not value.install:
+ continue
+
+ if not value.name:
+ # the name is missing (for some reason)
+ # we add it manually from the variable name
+ value.name = snake_case_to_kebab_case(name)
+ if value.name in klass_signals:
+ raise ValueError(f"Signal {value.name} has already been registered.")
+ klass_signals = klass_signals | value.serialize()
+ scanned_signals.append(value)
+
+ for signal in scanned_signals:
+ if not signal.func:
+ continue
+ fname = "do_" + name
+ if not hasattr(klass, fname):
+ setattr(klass, fname, signal.func)
+
+ # all aboard...
+ setattr(klass, "__gsignals__", klass_signals)
+ return
+
+
+@dataclass
+class Builder(Generic[G]):
+ parent: G
+
+ def unwrap(self) -> G:
+ return self.parent
+
+ def __getattr__(self, name: str):
+ return lambda *args, **kwargs: (
+ getattr(self.parent, name)(*args, **kwargs),
+ self,
+ )[1]
+
+
+@override(GObject, "Object")
+@override(gi.overrides.GObject, "Object")
+class Service(GObject.Object, Generic[P, T]):
+ """
+ Base service class
+
+ Handles constructor connections, bindings and builders
+ """
+
+ __gsignals__ = {}
+ __gproperties__ = {}
+ props: list[Any]
+
+ def __init__(self, **kwargs):
+ """Default service constructor (base constructor)
+
+ :param **kwargs: mapped to signal connections (e.g. `on_clicked=lambda *_: ...` connects the given function to the signal "clicked", `notify_my_property=my_func` connects the given function to the signal "notify::my-property")
+ """
+ super().__init__(**self.filter_kwargs(kwargs))
+ self._builder: Optional[Builder] = None
+ self.do_connect_kwargs(kwargs)
+
+ @overload
+ def build(
+ self,
+ callback: None = None,
+ *args: P.args,
+ **kwargs: P.kwargs,
+ ) -> Builder[Self]: ...
+
+ @overload
+ def build(
+ self,
+ callback: Callable[Concatenate[Self, Builder[Self], P], Any]
+ | Callable[Concatenate[Self, P], Any]
+ | Callable[P, Any],
+ *args: P.args,
+ **kwargs: P.kwargs,
+ ) -> Self: ...
+
+ def build(
+ self,
+ callback: Optional[
+ Callable[Concatenate[Self, Builder[Self], P], Any]
+ | Callable[Concatenate[Self, P], Any]
+ | Callable[P, Any]
+ ] = None,
+ *args: P.args,
+ **kwargs: P.kwargs,
+ ) -> Union[Builder[Self], Self]:
+ """Get an instance of a `Builder` that holds a reference to this service
+
+ Builders enable you of doing extra configuration after the initialization
+ of a service with ease, it makes you able to either have a callback function
+ that does all the extra setup or chain all your setup calls at once
+
+ **Examples:**
+
+ .. code-block:: python
+
+ my_service = (
+ MyService()
+ .build()
+ # beginning of method chaining
+ .useful_method()
+ .other_useful_method()
+ .set_something("something")
+ .unwrap() # unwrap self (the service) and get a reference to it
+ )
+
+ # when passing a setup function it returns self by default
+ my_service = MyService().build(lambda self, builder: self.do_something())
+ my_service = MyService().build(
+ lambda self, builder: (
+ builder.useful_method()
+ .other_useful_method()
+ .set_something("something")
+ # no need to do `.unwrap()` since we don't really need the value of self
+ )
+ )
+
+
+ :param callback: an optional callback to use instead of chaining method calls, defaults to None
+ :type callback: Optional[Callable[Concatenate[Self, Builder[Self], P], Any] | Callable[Concatenate[Self, P], Any] | Callable[P, Any]], optional
+ :return: a newly created builder (or a cached one if found)
+ :rtype: Union[Builder[Self], Self]
+ """
+ if not self._builder:
+ self._builder = Builder(self)
+ if callable(callback):
+ make_arguments_ignorable(callback)(self, self._builder, *args, **kwargs)
+ return self
+ return self._builder
+
+ def bind(
+ self,
+ source_property: str,
+ target_property: str,
+ target_object: GObject.Object,
+ transform_to: Callable[[GObject.Binding, Any], Any] | None = None,
+ transform_from: Callable[[GObject.Binding, Any], Any] | None = None,
+ flags: Literal["default", "bidirectional", "sync-create", "invert-boolean"]
+ | GObject.BindingFlags = GObject.BindingFlags.DEFAULT,
+ ):
+ return self.bind_property(
+ source_property,
+ target_object,
+ target_property,
+ get_enum_member(
+ GObject.BindingFlags, flags, default=GObject.BindingFlags.DEFAULT
+ ),
+ transform_to or (lambda _, v: v),
+ transform_from or (lambda _, v: v),
+ )
+
+ def emit(self, signal_name: str, *args, **kwargs) -> None:
+ return super().emit(signal_name, *args, **kwargs) # type: ignore
+
+ def connect(
+ self,
+ signal_name: str,
+ callback: Callable[Concatenate[Self, P], Any] | Callable,
+ *args,
+ ignore_missing: bool = True,
+ ) -> int:
+ return super().connect(
+ signal_name,
+ make_arguments_ignorable(callback) if ignore_missing else callback,
+ *args,
+ ) # type: ignore
+
+ @staticmethod
+ def filter_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
+ return {
+ k: v
+ for k, v in kwargs.items()
+ if not k.lower().startswith(("on_", "notify_"))
+ }
+
+ @staticmethod
+ def get_connectables_for_kwargs(
+ kwargs: dict[str, Any],
+ ) -> Generator[tuple[str, Callable[..., Any]], None, None]:
+ for key, value in zip(kwargs.keys(), kwargs.values()):
+ if key.startswith("on_"):
+ yield snake_case_to_kebab_case(key[3:]), value
+ elif key.startswith("notify_"):
+ # yield a connectable property
+ yield f"notify::{snake_case_to_kebab_case(key[7:])}", value
+ return
+
+ def do_connect_kwargs(self, kwargs: dict[str, Any]) -> None:
+ for name, callback in self.get_connectables_for_kwargs(kwargs):
+ self.connect(name, callback) # type: ignore
+ return
+
+ # overrides
+ def set_property(self, property_name: str, value: Any) -> None:
+ return super().set_property(property_name, value) # type: ignore
+
+ def get_property(self, property_name: str) -> Any:
+ return super().get_property(property_name) # type: ignore
+
+ def set_properties(self, *args, **kwargs) -> None:
+ return super().set_properties(*args, **kwargs) # type: ignore
+
+ def get_signal_names(self) -> list[str]:
+ return GObject.signal_list_names(self) # type: ignore
+
+ def get_signal_ids(self) -> list[int]:
+ return GObject.signal_list_ids(self) # type: ignore
+
+ def get_properties(self) -> list[GObject.ParamSpec]:
+ return GObject.list_properties(self) # type: ignore
+
+ def notify(self, *properties: str):
+ for prop in properties:
+ super().notify(prop)
+ return
+
+ def notify_all(self):
+ for pspec in self.get_properties():
+ self.notify(pspec.get_nick() or pspec.get_name())
+ return
+
+ # set/get properties via [] accessor
+ def __getitem__(self, key: str) -> Any:
+ assert isinstance(key, str)
+ return self.get_property(key)
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ assert isinstance(key, str)
+ return self.set_property(key, value)
+
+ # extras
+ def __len__(self) -> int:
+ return len(self.get_properties()) + len(self.get_signal_names())
+
+ def __int__(self) -> int:
+ return self.__len__()
+
+
+__all__ = ["Signal", "Property", "Builder", "Service"]
diff --git a/fabric/fabric/core/widgets/__init__.py b/fabric/fabric/core/widgets/__init__.py
new file mode 100644
index 0000000..57e1ff2
--- /dev/null
+++ b/fabric/fabric/core/widgets/__init__.py
@@ -0,0 +1,3 @@
+from .wm import WorkspaceButton, Workspaces, ActiveWindow, Language
+
+__all__ = ["WorkspaceButton", "Workspaces", "ActiveWindow", "Language"]
diff --git a/fabric/fabric/core/widgets/wm.py b/fabric/fabric/core/widgets/wm.py
new file mode 100644
index 0000000..71eb939
--- /dev/null
+++ b/fabric/fabric/core/widgets/wm.py
@@ -0,0 +1,287 @@
+import gi
+import re
+from loguru import logger
+from typing import Literal
+from collections.abc import Iterable, Callable
+from fabric.core.service import Signal, Property
+from fabric.widgets.box import Box
+from fabric.widgets.button import Button
+from fabric.widgets.eventbox import EventBox
+from fabric.utils.helpers import FormattedString, truncate
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk
+
+
+class WorkspaceButton(Button):
+ @Property(int, "readable")
+ def id(self) -> int:
+ return self._id
+
+ @Property(bool, "read-write", default_value=False)
+ def active(self) -> bool:
+ return self._active
+
+ @active.setter
+ def active(self, value: bool):
+ self._active = value
+ if value is True:
+ self.urgent = False
+ (self.remove_style_class if not value else self.add_style_class)("active")
+ return self.do_bake_label()
+
+ @Property(bool, "read-write", default_value=False)
+ def urgent(self) -> bool:
+ return self._urgent
+
+ @urgent.setter
+ def urgent(self, value: bool):
+ self._urgent = value
+ (self.remove_style_class if not value else self.add_style_class)("urgent")
+ return self.do_bake_label()
+
+ @Property(bool, "read-write", default_value=True)
+ def empty(self) -> bool:
+ return self._empty
+
+ @empty.setter
+ def empty(self, value: bool):
+ self._empty = value
+ (self.remove_style_class if not value else self.add_style_class)("empty")
+ return self.do_bake_label()
+
+ def __init__(
+ self,
+ id: int,
+ label: FormattedString | str | None = None,
+ image: Gtk.Image | None = None,
+ child: Gtk.Widget | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ super().__init__(
+ None,
+ image,
+ child,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self._id: int = id
+ self._label: FormattedString | None = (
+ FormattedString(label) if isinstance(label, str) else label
+ )
+ self._active: bool = False
+ self._urgent: bool = False
+ self._empty: bool = True
+
+ self.active = False
+ self.urgent = False
+ self.empty = True
+
+ def do_bake_label(self):
+ if not self._label:
+ return
+ return self.set_label(self._label.format(button=self))
+
+
+class Workspaces(EventBox):
+ @staticmethod
+ def default_buttons_factory(workspace_id: int):
+ return WorkspaceButton(id=workspace_id, label=str(workspace_id))
+
+ @Signal
+ def workspace_activated(self, workspace_id: int):
+ if workspace_id == self._active_workspace:
+ return
+
+ if self._active_workspace is not None and (
+ old_btn := self._buttons.get(self._active_workspace)
+ ):
+ old_btn.active = False
+
+ self._active_workspace = workspace_id
+ if not (btn := self.lookup_or_bake_button(workspace_id)):
+ return
+
+ btn.urgent = False
+ btn.active = True
+
+ if btn in self._container.children:
+ return
+ return self.insert_button(btn)
+
+ @Signal
+ def workspace_created(self, workspace_id: int):
+ if not (btn := self.lookup_or_bake_button(workspace_id)):
+ return
+
+ btn.empty = False
+ if btn in self._buttons_preset:
+ return
+ return self.insert_button(btn)
+
+ @Signal
+ def workspace_destroyed(self, workspace_id: int):
+ if not (btn := self._buttons.get(workspace_id)):
+ return # doesn't exist, skip
+
+ btn.active = False
+ btn.urgent = False
+ btn.empty = True
+
+ if btn in self._buttons_preset:
+ return
+ return self.remove_button(btn)
+
+ @Signal
+ def urgent(self, workspace_id: int):
+ if not (btn := self._buttons.get(workspace_id)):
+ return # doesn't exist, skip
+
+ btn.urgent = True
+ return logger.info(f"[Workspaces] workspace {workspace_id} is now urgent")
+
+ # user interaction (e.g. scrolling)
+ @Signal
+ def action_next(self): ...
+
+ @Signal
+ def action_previous(self): ...
+
+ @Signal
+ def button_clicked(self, button: WorkspaceButton): ...
+
+ def __init__(
+ self,
+ buttons: Iterable[WorkspaceButton] | None = None,
+ buttons_factory: Callable[[int], WorkspaceButton | None]
+ | None = default_buttons_factory,
+ invert_scroll: bool = False,
+ **kwargs,
+ ):
+ super().__init__(events="scroll")
+
+ self._container = Box(**kwargs)
+
+ self.children = self._container
+
+ self._active_workspace: int | None = None
+ self._buttons: dict[int, WorkspaceButton] = {}
+ self._buttons_preset: list[WorkspaceButton] = [
+ button for button in buttons or ()
+ ]
+ self._buttons_factory = buttons_factory
+ self._invert_scroll = invert_scroll
+
+ for btn in self._buttons_preset:
+ self.insert_button(btn)
+
+ def do_handle_scroll(self, _, event: Gdk.EventScroll): # TODO: abstract
+ match event.direction: # type: ignore
+ case Gdk.ScrollDirection.UP:
+ self.action_next() if not self._invert_scroll else self.action_previous()
+ logger.info("[Workspaces] Moving to the next workspace")
+ case Gdk.ScrollDirection.DOWN:
+ self.action_previous() if not self._invert_scroll else self.action_next()
+ logger.info("[Workspaces] Moving to the previous workspace")
+ case _:
+ return logger.warning(
+ f"[Workspaces] Unknown scroll direction ({event.direction})" # type: ignore
+ )
+ return
+
+ def insert_button(self, button: WorkspaceButton) -> None:
+ self._buttons[button.id] = button
+ self._container.add(button)
+ button.connect("clicked", self.do_handle_button_press)
+ return self.reorder_buttons()
+
+ def reorder_buttons(self):
+ for _, child in sorted(self._buttons.items(), key=lambda i: i[0]):
+ self._container.reorder_child(child, (child.id - 1))
+ return
+
+ def remove_button(self, button: WorkspaceButton) -> None:
+ if self._buttons.pop(button.id, None) is not None:
+ self._container.remove(button)
+ return button.destroy()
+
+ def lookup_or_bake_button(self, workspace_id: int) -> WorkspaceButton | None:
+ if not (btn := self._buttons.get(workspace_id)) and self._buttons_factory:
+ btn = self._buttons_factory(workspace_id)
+ return btn
+
+ def do_handle_button_press(self, button: WorkspaceButton): # TODO: abstract
+ self.button_clicked(button)
+ return logger.info(f"[Workspaces] Moved to workspace {button.id}")
+
+
+class ActiveWindow(Button):
+ @Signal
+ def window_activated(self, window_class: str, window_title: str):
+ return self.set_label(
+ self.formatter.format(win_class=window_class, win_title=window_title)
+ )
+
+ def __init__(
+ self,
+ formatter: FormattedString = FormattedString(
+ "{'Desktop' if not win_title else truncate(win_title, 42)}",
+ truncate=truncate,
+ ),
+ # TODO: hint super's kwargs
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.formatter = formatter
+
+
+class Language(Button):
+ @Signal
+ def layout_changed(self, language: str, keyboard: str) -> bool:
+ matched: bool = False
+
+ if re.match(self.keyboard, keyboard) and (matched := True):
+ self.set_label(self.formatter.format(language=language))
+
+ logger.debug(
+ f"[Language] Keyboard: {keyboard}, Language: {language}, Match: {matched}"
+ )
+ return matched
+
+ def __init__(
+ self,
+ keyboard: str = ".*",
+ formatter: FormattedString = FormattedString("{language}"),
+ # TODO: hint super's kwargs
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.keyboard = keyboard
+ self.formatter = formatter
diff --git a/fabric/fabric/dbus_assets/__init__.py b/fabric/fabric/dbus_assets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/fabric/fabric/dbus_assets/org.Fabric.fabric.xml b/fabric/fabric/dbus_assets/org.Fabric.fabric.xml
new file mode 100644
index 0000000..0ec5845
--- /dev/null
+++ b/fabric/fabric/dbus_assets/org.Fabric.fabric.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fabric/fabric/dbus_assets/org.freedesktop.Notifications.xml b/fabric/fabric/dbus_assets/org.freedesktop.Notifications.xml
new file mode 100644
index 0000000..ed92aaf
--- /dev/null
+++ b/fabric/fabric/dbus_assets/org.freedesktop.Notifications.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fabric/fabric/dbus_assets/org.kde.StatusNotifierItem.xml b/fabric/fabric/dbus_assets/org.kde.StatusNotifierItem.xml
new file mode 100644
index 0000000..92bdde1
--- /dev/null
+++ b/fabric/fabric/dbus_assets/org.kde.StatusNotifierItem.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fabric/fabric/dbus_assets/org.kde.StatusNotifierWatcher.xml b/fabric/fabric/dbus_assets/org.kde.StatusNotifierWatcher.xml
new file mode 100644
index 0000000..eee00bf
--- /dev/null
+++ b/fabric/fabric/dbus_assets/org.kde.StatusNotifierWatcher.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fabric/fabric/hyprland/__init__.py b/fabric/fabric/hyprland/__init__.py
new file mode 100644
index 0000000..da09e9f
--- /dev/null
+++ b/fabric/fabric/hyprland/__init__.py
@@ -0,0 +1,3 @@
+from .service import Hyprland, HyprlandEvent, HyprlandReply
+
+__all__ = ["Hyprland", "HyprlandEvent", "HyprlandReply"]
diff --git a/fabric/fabric/hyprland/service.py b/fabric/fabric/hyprland/service.py
new file mode 100644
index 0000000..00638bc
--- /dev/null
+++ b/fabric/fabric/hyprland/service.py
@@ -0,0 +1,287 @@
+import os
+from loguru import logger
+from dataclasses import dataclass
+from collections.abc import Callable
+from typing import ParamSpec, Concatenate, Any
+from fabric.core.service import Service, Signal, Property
+
+from fabric.utils.helpers import idle_add
+from gi.repository import (
+ Gio,
+ GLib,
+)
+
+P = ParamSpec("P")
+HYPRLAND_COMMAND_BUFFER_SIZE = 1_048_576 # 12mb -> binary bytes
+
+
+# exceptions
+# TODO: use the rest of 'em
+class HyprlandError(Exception): ...
+
+
+class HyprlandSocketError(Exception): ...
+
+
+class HyprlandSocketNotFoundError(Exception): ...
+
+
+# dataclasses with frozen flag
+# to avoid unexpected changes
+@dataclass(frozen=True)
+class HyprlandEvent:
+ name: str
+ "the name of the received event"
+ data: list[str]
+ "the data gotten from event's body"
+ raw_data: bytes
+ "the data as it's from the socket's event, it may be formatted as following `event-name>>event-data1,event-data2`"
+
+
+@dataclass(frozen=True)
+class HyprlandReply:
+ command: str
+ "the passed in command"
+ reply: bytes
+ "the raw reply from Hyprland"
+ is_ok: bool
+ """
+ this indicates if the ran command has returned `ok` or not
+ if set to `False` this means either the command execution has failed
+ or the command itself doesn't return a indication on if it failed or not
+ (i.e commands that return data from Hyprland)
+ """
+
+
+class Hyprland(Service):
+ """
+ a connection to the Hyprland's socket
+ this can be used for ONLY sending commands or both sending and receiving events
+ """
+
+ EVENTS_SOCKET = COMMANDS_SOCKET = None
+ SOCKET_PATH = ""
+
+ # refs
+ # https://wiki.hyprland.org/IPC
+
+ @Property(bool, "readable", "is-ready", default_value=False)
+ def ready(self) -> bool:
+ return self._ready
+
+ @Signal
+ def ready(self):
+ return self.notify("ready")
+
+ @Signal("event", flags="detailed")
+ def event(self, event: object): ...
+
+ def __init__(self, commands_only: bool = False, **kwargs):
+ """
+ :param commands_only: set to `True` if you're going to use this connection for sending commands only, defaults to False
+ :type commands_only: bool, optional
+
+ NOTE: since version v0.40.0 of Hyprland, the IPC socket path has been changed from `/tmp/hypr/` to `$XDG_RUNTIME_DIR/hypr/`
+
+ this service is backward compaitible so this is just a friendly note
+ """
+ super().__init__(**kwargs)
+ self._ready = False
+ self.lookup_socket() # set the above constants
+
+ # all aboard...
+ if not commands_only:
+ self.event_socket_thread = GLib.Thread.new(
+ "hyprland-socket-service", self.event_socket_task, self.EVENTS_SOCKET
+ )
+
+ self._ready = True
+ self.ready.emit()
+
+ @staticmethod
+ def lookup_socket() -> tuple[Gio.UnixSocketAddress, Gio.UnixSocketAddress, str]:
+ if (
+ Hyprland.EVENTS_SOCKET and Hyprland.COMMANDS_SOCKET and Hyprland.SOCKET_PATH
+ ): # this _should_ handle "" as None
+ return (
+ Hyprland.EVENTS_SOCKET,
+ Hyprland.COMMANDS_SOCKET,
+ Hyprland.SOCKET_PATH,
+ )
+
+ runtime_dir = os.getenv("XDG_RUNTIME_DIR")
+ hyprland_sig = os.getenv("HYPRLAND_INSTANCE_SIGNATURE")
+
+ hyprland_dir = f"/tmp/hypr/{hyprland_sig}"
+ if not os.path.isdir(hyprland_dir):
+ # a new version of hyprland
+ hyprland_dir = f"{runtime_dir}/hypr/{hyprland_sig}"
+
+ if not os.path.isdir(hyprland_dir):
+ # hyprland is not running
+ raise HyprlandSocketNotFoundError(
+ "couldn't find Hyprland socket, is Hyprland running?"
+ )
+
+ Hyprland.EVENTS_SOCKET = Gio.UnixSocketAddress.new(
+ f"{hyprland_dir}/.socket2.sock"
+ )
+ Hyprland.COMMANDS_SOCKET = Gio.UnixSocketAddress.new(
+ f"{hyprland_dir}/.socket.sock"
+ )
+ Hyprland.SOCKET_PATH = hyprland_dir
+
+ return (
+ Hyprland.EVENTS_SOCKET,
+ Hyprland.COMMANDS_SOCKET,
+ Hyprland.SOCKET_PATH,
+ )
+
+ @staticmethod
+ def send_command(command: str) -> HyprlandReply:
+ """
+ send hyprctl-like commands over hyprland socket
+
+ example usage:
+ ```python
+ # next workspace...
+ Hyprland.send_command("/dispatch workspace e+1")
+ ```
+
+ :param command: the Hyprland command to send, see Hyprland's wiki for more info
+ :type command: str
+ :return: a command reply object contains the reply data from hyprland
+ :rtype: HyprlandReply
+ """
+ resp = b""
+ try:
+ _, socket_addr, *__ = Hyprland.lookup_socket()
+
+ # from Gio's docs:
+ # > GSocketClient is a lightweight object, you don't need to cache it.
+ # > You can just create a new one any time you need one.
+ client = Gio.SocketClient()
+ conn: Gio.SocketConnection = client.connect(socket_addr)
+ stream: Gio.OutputStream = conn.get_output_stream() # type: ignore
+
+ # write command's content
+ stream.write(command.encode()) # type: ignore
+ stream.flush() # type: ignore
+
+ # read return data
+ input_stream = Gio.DataInputStream.new(conn.get_input_stream()) # type: ignore
+ raw_data: GLib.Bytes = input_stream.read_bytes(HYPRLAND_COMMAND_BUFFER_SIZE)
+ resp: bytes = raw_data.get_data() # type: ignore
+ except Exception as e:
+ logger.error(
+ f"[HyprlandService] got error while sending command via socket ({e})"
+ )
+
+ return HyprlandReply(
+ command=command,
+ reply=resp,
+ is_ok=True if resp == b"ok" else False,
+ )
+
+ @staticmethod
+ def send_command_async(
+ command: str,
+ callback: Callable[Concatenate[HyprlandReply, P], Any],
+ *args: P.args,
+ **kwargs: P.kwargs,
+ ) -> None:
+ """send hyprctl-like commands asynchronously
+ this function will act same as `send_command` but in a asynchronous manner
+
+ NOTE: `*args` and `**kwargs` are passed directly into your callback
+
+ :param command: the Hyprland command to send, see Hyprland's wiki for more info
+ :type command: str
+ :param callback: the callback on where would you like to receive the reply of the command
+ :type callback: Callable[[HyprlandReply], Any]
+ """
+ _, socket_addr, *_ = Hyprland.lookup_socket()
+
+ # from Gio's docs:
+ # > GSocketClient is a lightweight object, you don't need to cache it.
+ # > You can just create a new one any time you need one.
+ client = Gio.SocketClient()
+
+ def reader_callback(
+ input_stream: Gio.DataInputStream, res: Gio.AsyncResult, *_
+ ):
+ raw_data: GLib.Bytes = input_stream.read_bytes_finish(res)
+ resp: bytes = raw_data.get_data() # type: ignore
+ print("data if any: ", resp)
+ callback(
+ HyprlandReply(
+ command=command,
+ reply=resp,
+ is_ok=True if resp == b"ok" else False,
+ ),
+ *args,
+ **kwargs,
+ )
+
+ def client_callback(client: Gio.SocketClient, res: Gio.AsyncResult, *_):
+ conn: Gio.SocketConnection = client.connect_finish(res)
+ stream: Gio.OutputStream = conn.get_output_stream() # type: ignore
+ input_stream = Gio.DataInputStream.new(conn.get_input_stream()) # type: ignore
+
+ # write command's content
+ stream.write_async( # type: ignore
+ command.encode(),
+ HYPRLAND_COMMAND_BUFFER_SIZE,
+ None, # type: ignore
+ None,
+ None,
+ None,
+ )
+ stream.flush_async(1, None, None, None) # type: ignore
+
+ # the connection object must be have a living pointer
+ # in our case it's passed over to reader_callback
+ # if there's no references to the connection found
+ # the GC will collect the connection object and thus the input stream
+ # this will result in a undefined behaviour
+ # this is a bug with PyGObject (or at least an issue with the ref counter)
+ input_stream.read_bytes_async(
+ HYPRLAND_COMMAND_BUFFER_SIZE, 1, None, reader_callback, conn
+ )
+
+ client.connect_async(socket_addr, None, client_callback, None)
+
+ return None
+
+ def event_socket_task(self, socket_addr: Gio.UnixSocketAddress) -> bool:
+ client = Gio.SocketClient()
+ conn: Gio.SocketConnection = client.connect(socket_addr)
+ stream: Gio.InputStream = conn.get_input_stream() # type: ignore
+ input_stream = Gio.DataInputStream.new(stream)
+
+ while not stream.is_closed():
+ raw_data: list[bytes] = input_stream.read_line(None) # type: ignore
+ if b">>" not in raw_data[0]:
+ # hyprland broke, it happens...
+ logger.error(
+ f"[HyprlandService] hyprland returned wrong data ({raw_data})"
+ )
+ continue
+
+ idle_add(self.handle_raw_event, raw_data[0])
+
+ logger.warning("[HyprlandService] events socket thread ended")
+ return False
+
+ def handle_raw_event(self, raw_event: bytes):
+ raw_listed = str((raw_event).decode()).split(">>")
+ if len(raw_listed) < 1:
+ return # how?.. i mean Why?
+
+ event_name: str = raw_listed[0]
+ raw_event_body: str = "".join(raw_listed[1:])
+ event_body: list[str] = raw_event_body.split(",")
+
+ event = HyprlandEvent(event_name, event_body, raw_event)
+
+ return self.emit(f"event::{event.name}", event)
diff --git a/fabric/fabric/hyprland/widgets.py b/fabric/fabric/hyprland/widgets.py
new file mode 100644
index 0000000..6a84f57
--- /dev/null
+++ b/fabric/fabric/hyprland/widgets.py
@@ -0,0 +1,271 @@
+import json
+from loguru import logger
+from typing import TypeVar
+from collections.abc import Iterable, Callable
+
+from fabric.hyprland.service import Hyprland, HyprlandEvent
+from fabric.core.widgets import WorkspaceButton, Workspaces, ActiveWindow, Language
+from fabric.utils.helpers import FormattedString, bulk_connect, truncate
+
+
+connection: Hyprland | None = None
+
+
+def get_hyprland_connection() -> Hyprland:
+ global connection
+ if not connection:
+ connection = Hyprland()
+
+ return connection
+
+
+class HyprlandWorkspaces(Workspaces):
+ def __init__(
+ self,
+ buttons: Iterable[WorkspaceButton] | None = None,
+ buttons_factory: Callable[[int], WorkspaceButton | None]
+ | None = Workspaces.default_buttons_factory,
+ invert_scroll: bool = False,
+ empty_scroll: bool = False,
+ **kwargs,
+ ):
+ super().__init__(buttons, buttons_factory, invert_scroll, **kwargs)
+ self.connection = get_hyprland_connection()
+
+ self._empty_scroll = empty_scroll
+ bulk_connect(
+ self.connection,
+ {
+ "event::workspacev2": self.on_workspace,
+ "event::focusedmonv2": self.on_monitor,
+ "event::createworkspacev2": self.on_create_workspace,
+ "event::destroyworkspacev2": self.on_destroy_workspace,
+ "event::urgent": self.on_urgent,
+ },
+ )
+
+ # all aboard...
+ if self.connection.ready:
+ self.on_ready()
+ else:
+ self.connection.connect("event::ready", self.on_ready)
+ self.connect("scroll-event", self.do_handle_scroll)
+
+ def on_ready(self):
+ open_workspaces: tuple[int, ...] = tuple(
+ workspace["id"]
+ for workspace in json.loads(
+ self.connection.send_command("j/workspaces").reply.decode()
+ )
+ )
+ active_workspace = json.loads(
+ self.connection.send_command("j/activeworkspace").reply.decode()
+ )["id"]
+
+ for id in open_workspaces:
+ self.workspace_created(id)
+ if id == active_workspace:
+ self.workspace_activated(id)
+ return
+
+ def on_monitor(self, _, event: HyprlandEvent):
+ if len(event.data) != 2:
+ return
+ return self.workspace_activated(int(event.data[1]))
+
+ def on_workspace(self, _, event: HyprlandEvent):
+ if len(event.data) != 2:
+ return
+ return self.workspace_activated(int(event.data[0]))
+
+ def on_create_workspace(self, _, event: HyprlandEvent):
+ if len(event.data) != 2:
+ return
+ return self.workspace_created(int(event.data[0]))
+
+ def on_destroy_workspace(self, _, event: HyprlandEvent):
+ if len(event.data) != 2:
+ return
+ return self.workspace_destroyed(int(event.data[0]))
+
+ def on_urgent(self, _, event: HyprlandEvent):
+ if len(event.data) != 1:
+ return
+
+ clients = json.loads(self.connection.send_command("j/clients").reply.decode())
+ clients_map = {client["address"]: client for client in clients}
+ urgent_client: dict = clients_map.get("0x" + event.data[0], {})
+ if not (raw_workspace := urgent_client.get("workspace")):
+ return logger.warning(
+ f"[Workspaces] received urgent signal, but data received ({event.data[0]}) is incorrect, skipping..."
+ )
+ return self.urgent(int(raw_workspace["id"]))
+
+ def do_action_next(self):
+ return self.connection.send_command(
+ f"batch/dispatch workspace {'e' if not self._empty_scroll else ''}+1"
+ )
+
+ def do_action_previous(self):
+ return self.connection.send_command(
+ f"batch/dispatch workspace {'e' if not self._empty_scroll else ''}-1"
+ )
+
+ def do_button_clicked(self, button: WorkspaceButton):
+ return self.connection.send_command(f"batch/dispatch workspace {button.id}")
+
+
+class HyprlandActiveWindow(ActiveWindow):
+ def __init__(
+ self,
+ formatter: FormattedString = FormattedString(
+ "{'Desktop' if not win_title else truncate(win_title, 42)}",
+ truncate=truncate,
+ ),
+ # TODO: hint super's kwargs
+ **kwargs,
+ ):
+ super().__init__(formatter, **kwargs)
+
+ self.connection = get_hyprland_connection()
+ bulk_connect(
+ self.connection,
+ {
+ "event::activewindow": self.on_active_window,
+ "event::closewindow": self.on_close_window,
+ },
+ )
+
+ # all aboard...
+ if self.connection.ready:
+ self.on_ready()
+ else:
+ self.connection.connect("event::ready", self.on_ready)
+
+ def on_ready(self):
+ return self.do_initialize(), logger.info(
+ "[ActiveWindow] Connected to the hyprland socket"
+ )
+
+ def on_close_window(self, _, event: HyprlandEvent):
+ if len(event.data) < 1:
+ return
+ return self.do_initialize(), logger.info(
+ f"[ActiveWindow] Closed window 0x{event.data[0]}"
+ )
+
+ def on_active_window(self, _, event: HyprlandEvent):
+ if len(event.data) < 2:
+ return
+ return self.window_activated(event.data[0], event.data[1]), logger.info(
+ f"[ActiveWindow] Activated window {event.data[0]}, {event.data[1]}"
+ )
+
+ def do_initialize(self):
+ win_data: dict = json.loads(
+ self.connection.send_command("j/activewindow").reply.decode()
+ )
+ win_class = win_data.get("class", "unknown")
+ win_title = win_data.get("title", win_class)
+
+ return self.window_activated(win_class, win_title)
+
+
+class HyprlandLanguage(Language):
+ def __init__(
+ self,
+ keyboard: str = ".*",
+ formatter: FormattedString = FormattedString("{language}"),
+ # TODO: hint super's kwargs
+ **kwargs,
+ ):
+ super().__init__(keyboard, formatter, **kwargs)
+
+ self.connection = get_hyprland_connection()
+ self.connection.connect("event::activelayout", self.on_active_layout)
+
+ # all aboard...
+ if self.connection.ready:
+ self.on_ready()
+ else:
+ self.connection.connect("event::ready", self.on_ready)
+
+ def on_ready(self):
+ return self.do_initialize(), logger.info(
+ "[Language] Connected to the hyprland socket"
+ )
+
+ def on_active_layout(self, _, event: HyprlandEvent):
+ if len(event.data) < 2:
+ return logger.warning(
+ f"[Language] got invalid event data from hyprland, raw data is\n{event.raw_data}"
+ )
+ keyboard, language, *_ = event.data
+
+ return self.layout_changed(language, keyboard)
+
+ def do_initialize(self):
+ devices: dict[str, list[dict[str, str]]] = json.loads(
+ self.connection.send_command("j/devices").reply.decode()
+ )
+ if not devices or not (keyboards := devices.get("keyboards")):
+ return logger.warning(
+ f"[Language] coulnd't get devices from hyprctl, gotten data\n{devices}"
+ )
+
+ language: str | None = None
+ for kb in keyboards:
+ if (
+ not (kb_name := kb.get("name"))
+ or not (language := kb.get("active_keymap"))
+ or not self.layout_changed(language, kb_name)
+ ):
+ continue
+
+ logger.debug(
+ f"[Language] found language: {language} for keyboard {kb_name}"
+ )
+ break
+
+ return (
+ logger.info(
+ f"[Language] Could not find language for keyboard: {self.keyboard}, gotten keyboards: {keyboards}"
+ )
+ if not language
+ else logger.info(
+ f"[Language] Set language: {language} for keyboard: {self.keyboard}"
+ )
+ )
+
+
+# TODO: remove in v0.0.3
+T = TypeVar("T")
+
+
+def __bake_deprecation_message(obj: T, old_name: str) -> T:
+ def wrapper():
+ logger.warning(
+ f"[{old_name}][DEPRECATION] `{old_name}` has been renamed to `{obj.__name__}`. "
+ "Window manager-specific widget classes are now prefixed with their corresponding window manager name. "
+ "Please update your imports accordingly."
+ )
+ return obj
+
+ return wrapper # type: ignore
+
+
+Language = __bake_deprecation_message(HyprlandLanguage, "Language")
+Workspaces = __bake_deprecation_message(HyprlandWorkspaces, "Workspaces")
+ActiveWindow = __bake_deprecation_message(HyprlandActiveWindow, "ActiveWindow")
+
+
+__all__ = [
+ "HyprlandWorkspaces",
+ "HyprlandActiveWindow",
+ "HyprlandLanguage",
+ "WorkspaceButton",
+ "get_hyprland_connection",
+ "Language",
+ "Workspaces",
+ "ActiveWindow",
+]
diff --git a/fabric/fabric/notifications/__init__.py b/fabric/fabric/notifications/__init__.py
new file mode 100644
index 0000000..4c9f32d
--- /dev/null
+++ b/fabric/fabric/notifications/__init__.py
@@ -0,0 +1,18 @@
+from .service import (
+ Notifications,
+ Notification,
+ NotificationAction,
+ NotificationImagePixmap,
+ NotificationCloseReason,
+ NotificationSerializedData,
+)
+
+
+__all__ = [
+ "Notifications",
+ "Notification",
+ "NotificationAction",
+ "NotificationImagePixmap",
+ "NotificationCloseReason",
+ "NotificationSerializedData",
+]
diff --git a/fabric/fabric/notifications/service.py b/fabric/fabric/notifications/service.py
new file mode 100644
index 0000000..4c30ef9
--- /dev/null
+++ b/fabric/fabric/notifications/service.py
@@ -0,0 +1,639 @@
+import gi
+import base64
+from enum import Enum
+from loguru import logger
+from dataclasses import dataclass
+from typing import cast, Literal, TypedDict, Any
+from fabric.core.service import Service, Signal, Property
+from fabric.utils.helpers import load_dbus_xml, get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gio, GLib, GdkPixbuf
+
+
+NOTIFICATIONS_BUS_NAME = "org.freedesktop.Notifications"
+NOTIFICATIONS_BUS_PATH = "/org/freedesktop/Notifications"
+NOTIFICATIONS_BUS_IFACE_NODE = load_dbus_xml(
+ "../dbus_assets/org.freedesktop.Notifications.xml",
+)
+
+
+class NotificationCloseReason(Enum):
+ """A reason for which a notification was closed"""
+
+ EXPIRED = 1
+ DISMISSED_BY_USER = 2
+ CLOSED_BY_APPLICATION = 3
+ UNKNOWN = 4
+
+
+class NotificationImagePixmap:
+ """A class for storing image data associated with a notification"""
+
+ @classmethod
+ def deserialize(
+ cls, data: tuple[int, int, int, bool, int, int, str]
+ ) -> "NotificationImagePixmap":
+ """Load image data from a serialized data tuple (using the `serialize` method) and return the newly created Pixmap object
+
+ :param data: the tuple which is holding the image's data
+ :type data: tuple[int, int, int, bool, int, int, str]
+ :return: the newly loaded image pixmap
+ :rtype: NotificationImagePixmap
+ """
+ self = cls.__new__(cls)
+
+ (
+ self.width,
+ self.height,
+ self.rowstride,
+ self.has_alpha,
+ self.bits_per_sample,
+ self.channels,
+ pixmap_data,
+ ) = data
+
+ # if this doesn't work, please report.
+ loader = GdkPixbuf.PixbufLoader.new_with_type("png")
+ decoded_data = base64.b64decode(pixmap_data)
+ bytes_data = GLib.Bytes.new(decoded_data)
+ loader.write_bytes(bytes_data) # type: ignore
+ loader.close()
+
+ self._pixbuf = loader.get_pixbuf() # type: ignore
+ self.byte_array = None
+
+ return self
+
+ def __init__(self, raw_variant: GLib.Variant):
+ # manually unpack children so we don't lock up the interpreter
+ # trying to unpack hugely sized object recursively
+ self.width = raw_variant.get_child_value(0).unpack() # type: ignore
+ self.height = raw_variant.get_child_value(1).unpack() # type: ignore
+ self.rowstride = raw_variant.get_child_value(2).unpack() # type: ignore
+ self.has_alpha = raw_variant.get_child_value(3).unpack() # type: ignore
+ self.bits_per_sample = raw_variant.get_child_value(4).unpack() # type: ignore
+ self.channels = raw_variant.get_child_value(5).unpack() # type: ignore
+ self.byte_array = raw_variant.get_child_value(6).get_data_as_bytes() # type: ignore
+
+ self._pixbuf: GdkPixbuf.Pixbuf | None = None
+
+ def as_pixbuf(self) -> GdkPixbuf.Pixbuf:
+ """Load a `Pixbuf` variant of this pixmap
+
+ :return: the newly created pixbuf in which it has the contents of this pixmap
+ :rtype: GdkPixbuf.Pixbuf
+ """
+ if self._pixbuf is not None:
+ return self._pixbuf
+
+ self._pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
+ self.byte_array,
+ GdkPixbuf.Colorspace.RGB,
+ self.has_alpha,
+ self.bits_per_sample,
+ self.width,
+ self.height,
+ self.rowstride,
+ )
+ return self._pixbuf
+
+ def serialize(self) -> tuple[int, int, int, bool, int, int, str]:
+ """Serialize this pixmap image into a tuple for ease of carrying and saving
+
+ :return: the serialized pixmap image data
+ :rtype: tuple[int, int, int, bool, int, int, str]
+ """
+ return (
+ self.width,
+ self.height,
+ self.rowstride,
+ self.has_alpha,
+ self.bits_per_sample,
+ self.channels,
+ base64.b64encode(
+ cast(bytes, self.as_pixbuf().save_to_bufferv("png", [], [])[1])
+ ).decode(),
+ )
+
+
+@dataclass
+class NotificationAction:
+ """A notification action that can be invoked"""
+
+ identifier: str
+ label: str
+
+ parent: "Notification"
+
+ def invoke(self):
+ "Invoke this action"
+ return self.parent.invoke_action(self.identifier)
+
+
+NotificationSerializedData = TypedDict(
+ "NotificationSerializedData",
+ {
+ "id": int,
+ "replaces-id": int,
+ "app-name": str,
+ "app-icon": str,
+ "summary": str,
+ "body": str,
+ "timeout": int,
+ "urgency": int,
+ "actions": list[tuple[str, str]],
+ "image-file": str | None,
+ "image-pixmap": tuple[int, int, int, bool, int, int, str] | None,
+ },
+)
+
+
+class Notification(Service):
+ """The notification class holds all the data of a specific notification (such as actions, images and sender's info)"""
+
+ @Signal
+ def closed(self, reason: object) -> None: ...
+
+ @Signal
+ def action_invoked(self, action: str) -> None: ...
+
+ @Property(str, "readable")
+ def app_name(self) -> str:
+ """The display name of the application sent this notification
+
+ :return: sender's display name
+ :rtype: str
+ """
+ return self._app_name
+
+ @Property(str, "readable")
+ def app_icon(self) -> str:
+ """An optional application icon name
+
+ :return: sender's named icon (or None)
+ :rtype: str
+ """
+ return self._app_icon
+
+ @Property(str, "readable")
+ def summary(self) -> str:
+ return self._summary
+
+ @Property(str, "readable")
+ def body(self) -> str:
+ """A multi-line body of text given by the sender (might contain markup)
+
+ :return: this notification's body text
+ :rtype: str
+ """
+ return self._body
+
+ @Property(int, "readable")
+ def id(self) -> int:
+ """The uniuqe identifier of this notification
+
+ :return: this notification's id
+ :rtype: int
+ """
+ return self._id
+
+ @Property(int, "readable")
+ def replaces_id(self) -> int:
+ """An optional ID of an existing notification that this notification is intended to replace
+
+ :return: the id of the targeted notification (or None)
+ :rtype: int
+ """
+ return self._replaces_id
+
+ @Property(int, "readable")
+ def timeout(self) -> int:
+ """
+ Expiration timeout (in milliseconds) since the display of the notification
+ at which the notification should automatically get closed
+
+ :return: timeout in milliseconds, -1 means whatever the user decides, 0 means the notification is supposed to never get closed
+ :rtype: int
+ """
+ return self._timeout
+
+ @Property(int, "readable")
+ def urgency(self) -> int:
+ """Urgency level of this notification
+
+ :return: the urgency level as an integer, 0 for low, 1 for normal, 2 for critical
+ :rtype: int
+ """
+ return self._urgency
+
+ @Property(list[NotificationAction], "readable")
+ def actions(self) -> list[NotificationAction]:
+ """A list of all the action this notification has
+
+ :return: a list of notification actions
+ :rtype: list[NotificationAction]
+ """
+ return self._actions
+
+ @Property(NotificationImagePixmap, "readable")
+ def image_pixmap(self) -> NotificationImagePixmap:
+ """Raw image data supplied by the sender (if any)
+
+ :return: raw image data stored in a pixmap object
+ :rtype: NotificationImagePixmap
+ """
+ return self._image_pixmap # type: ignore
+
+ @Property(str, "readable")
+ def image_file(self) -> str:
+ """The image file path provided by the sender for this notification (if any)
+
+ :return: the image file path
+ :rtype: str
+ """
+ return self._image_file # type: ignore
+
+ @Property(GdkPixbuf.Pixbuf, "readable")
+ def image_pixbuf(self) -> GdkPixbuf.Pixbuf:
+ """A `Pixbuf` loaded from either `image-pixmap` or the `image-file` property
+
+ :return: the newly loaded image pixbuf
+ :rtype: GdkPixbuf.Pixbuf
+ """
+ if self.image_pixmap:
+ return self.image_pixmap.as_pixbuf()
+ if self.image_file:
+ return GdkPixbuf.Pixbuf.new_from_file(self.image_file)
+ return None # type: ignore
+
+ @classmethod
+ def deserialize(cls, data: NotificationSerializedData, **kwargs) -> "Notification":
+ """Deserialize a given serialized notification data into a newly created notification object
+
+ :param data: the serialized data to consume and covert to an object
+ :type data: NotificationSerializedData
+ :return: the newly created notification object
+ :rtype: Notification
+ """
+ self = cls.__new__(cls)
+ Service.__init__(self, **kwargs)
+
+ self._id = data["id"]
+ self._app_name = data["app-name"]
+ self._replaces_id = data["replaces-id"]
+ self._app_icon = data["app-icon"]
+
+ self._summary = data["summary"]
+ self._body = data["body"]
+
+ self._timeout = data["timeout"]
+ self._urgency = data["urgency"]
+
+ self._actions = [
+ NotificationAction(action[0], action[1], self) for action in data["actions"]
+ ]
+
+ self._image_file = data["image-file"]
+ self._image_pixmap = (
+ NotificationImagePixmap.deserialize(data["image-pixmap"])
+ if data["image-pixmap"]
+ else None
+ )
+
+ return self
+
+ def __init__(self, id: int, raw_variant: GLib.Variant, **kwargs):
+ super().__init__(**kwargs)
+ self._id: int = id
+
+ self._app_name: str = raw_variant.get_child_value(0).unpack() # type: ignore
+ self._replaces_id: int = raw_variant.get_child_value(1).unpack() # type: ignore
+ self._app_icon: str = raw_variant.get_child_value(2).unpack() # type: ignore
+ self._summary: str = raw_variant.get_child_value(3).unpack() # type: ignore
+ self._body: str = raw_variant.get_child_value(4).unpack() # type: ignore
+
+ raw_actions: list[str] = raw_variant.get_child_value(5).unpack() # type: ignore
+
+ self._actions: list[NotificationAction] = [
+ NotificationAction(raw_actions[i], raw_actions[i + 1], self)
+ for i in range(0, len(raw_actions), 2)
+ ]
+
+ self._hints: GLib.Variant = raw_variant.get_child_value(6) # type: ignore
+ self._timeout: int = raw_variant.get_child_value(7).unpack() # type: ignore
+
+ self._urgency: int = self.do_get_hint_entry("urgency") or 1 # type: ignore
+
+ self._image_file: str | None = self.do_get_hint_entry(
+ "image-path"
+ ) or self.do_get_hint_entry("image_path") # type: ignore
+
+ self._image_pixmap: NotificationImagePixmap | None = None
+ if raw_image_data := (
+ self.do_get_hint_entry("image-data", False)
+ or self.do_get_hint_entry("icon_data", False)
+ ):
+ self._image_pixmap = NotificationImagePixmap(raw_image_data)
+
+ def do_get_hint_entry(
+ self, entry_key: str, unpack: bool = True
+ ) -> GLib.Variant | Any | None:
+ variant = self._hints.lookup_value(entry_key)
+ if not unpack or not variant:
+ return variant
+ return variant.unpack() # type: ignore
+
+ def serialize(self) -> NotificationSerializedData:
+ """Serialize this notification into a dictionary that can easily get converted into JSON
+
+ :return: the serialized notification data (dict)
+ :rtype: NotificationSerializedData
+ """
+ return {
+ "id": self._id,
+ "replaces-id": self._replaces_id,
+ "app-name": self._app_name,
+ "app-icon": self._app_icon,
+ "summary": self._summary,
+ "body": self._body,
+ "timeout": self._timeout,
+ "urgency": self._urgency,
+ "actions": [(action.identifier, action.label) for action in self._actions],
+ "image-file": self._image_file,
+ "image-pixmap": self._image_pixmap.serialize()
+ if self._image_pixmap
+ else None,
+ }
+
+ def invoke_action(self, action: str):
+ """Invoke an action via its name"""
+ return self.action_invoked(action)
+
+ def close(
+ self,
+ reason: Literal[
+ "expired", "dismissed-by-user", "closed-by-application", "unknown"
+ ]
+ | NotificationCloseReason = NotificationCloseReason.DISMISSED_BY_USER,
+ ):
+ """Close this notification and notify the sender with a reason
+
+ :param reason: the reason behind the close of this notification, defaults to NotificationCloseReason.DISMISSED_BY_USER
+ :type reason: Literal["expired", "dismissed-by-user", "closed-by-application", "unknown"] | NotificationCloseReason, optional
+ """
+ return self.closed(get_enum_member(NotificationCloseReason, reason))
+
+
+class Notifications(Service):
+ """A server for watching in-coming notifications from running applications, in order for it to work, other notification daemons must be NOT running on the system"""
+
+ @Signal
+ def changed(self) -> None: ...
+
+ @Signal
+ def notification_added(self, notification_id: int) -> None:
+ self.notify("notifications")
+ self.changed()
+ return
+
+ @Signal
+ def notification_removed(self, notification_id: int) -> None:
+ self._notifications.pop(notification_id, None)
+ self.notify("notifications")
+ self.changed()
+ return
+
+ @Signal
+ def notification_closed(self, notification_id: int, reason: object) -> None:
+ self.notification_removed(notification_id)
+ self.do_emit_bus_signal(
+ "NotificationClosed",
+ GLib.Variant(
+ "(uu)", (notification_id, cast(NotificationCloseReason, reason).value)
+ ),
+ )
+ return
+
+ @Property(dict[int, Notification], "readable")
+ def notifications(self) -> dict[int, Notification]:
+ """A list of all the notifications received by this server
+
+ :return: the list of notifications
+ :rtype: dict[int, Notification]
+ """
+ return self._notifications
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._notifications: dict[int, Notification] = {}
+ self._connection: Gio.DBusConnection | None = None
+
+ self._counter = 0
+
+ # all aboard...
+ self.do_register()
+
+ def do_register(self) -> int: # the bus id
+ return Gio.bus_own_name(
+ Gio.BusType.SESSION,
+ NOTIFICATIONS_BUS_NAME,
+ Gio.BusNameOwnerFlags.NONE,
+ self.on_bus_acquired,
+ None,
+ lambda *_: logger.warning(
+ "[Notifications] couldn't own the DBus name, another notifications daemon is probably running."
+ ),
+ )
+
+ def on_bus_acquired(
+ self, conn: Gio.DBusConnection, name: str, user_data: object = None
+ ) -> None:
+ self._connection = conn
+ # we now own the name
+ for interface in NOTIFICATIONS_BUS_IFACE_NODE.interfaces:
+ cast(Gio.DBusInterface, interface)
+ if interface.name == name:
+ conn.register_object(
+ NOTIFICATIONS_BUS_PATH,
+ interface,
+ self.do_handle_bus_call, # type: ignore
+ )
+ return
+
+ def do_handle_bus_call(
+ self,
+ conn: Gio.DBusConnection,
+ sender: str,
+ path: str,
+ interface: str,
+ target: str,
+ params: tuple,
+ invocation: Gio.DBusMethodInvocation,
+ user_data: object = None,
+ ) -> None:
+ match target:
+ case "Get":
+ prop_name = params[1] if len(params) >= 1 else None
+ match prop_name:
+ case _:
+ invocation.return_value(None)
+ case "GetAll":
+ invocation.return_value(GLib.Variant("(a{sv})", ({},)))
+
+ case "CloseNotification":
+ notif_id = int(params[0])
+
+ self.notification_removed(notif_id)
+
+ invocation.return_value(None)
+
+ case "GetCapabilities":
+ invocation.return_value(
+ GLib.Variant(
+ "(as)",
+ (
+ [
+ "action-icons",
+ "actions",
+ "body",
+ "body-hyperlinks",
+ "body-images",
+ "body-markup",
+ # "icon-multi",
+ "icon-static",
+ "persistence",
+ # "sound",
+ ],
+ ),
+ )
+ )
+
+ case "GetServerInformation":
+ invocation.return_value(
+ GLib.Variant(
+ "(ssss)", ("fabric", "Fabric-Development", "0.0.2", "1.2")
+ )
+ )
+
+ case "Notify":
+ notif_id = self.new_notification_id()
+
+ self._notifications[notif_id] = Notification(
+ id=notif_id,
+ raw_variant=cast(GLib.Variant, params),
+ on_closed=self.do_handle_notification_closed,
+ on_action_invoked=self.do_handle_notification_action_invoke,
+ )
+ self.notification_added(notif_id)
+
+ invocation.return_value(GLib.Variant("(u)", (notif_id,)))
+
+ return conn.flush()
+
+ def do_emit_bus_signal(self, signal_name: str, params: GLib.Variant) -> None:
+ self._connection.emit_signal(
+ None,
+ NOTIFICATIONS_BUS_PATH,
+ NOTIFICATIONS_BUS_NAME,
+ signal_name,
+ params,
+ ) if self._connection is not None else None
+ return
+
+ def new_notification_id(self) -> int:
+ """Get the next notification id and increase the internal counter
+
+ :return: the next id
+ :rtype: int
+ """
+ self._counter += 1
+ return self._counter
+
+ def do_handle_notification_action_invoke(
+ self, notification: Notification, action: str
+ ):
+ # a pointer to a function is better than a new lambda on every notification
+ return self.invoke_notification_action(notification.id, action)
+
+ def do_handle_notification_closed(
+ self, notification: Notification, reason: NotificationCloseReason
+ ):
+ return self.close_notification(notification.id, reason)
+
+ def get_notification_from_id(self, notification_id: int) -> Notification | None:
+ """Lookup a notification via its identifier
+
+ :param notification_id: the notification's id (gotten from the `notification-added` signal)
+ :type notification_id: int
+ :return: the desired notification object (if found)
+ :rtype: Notification | None
+ """
+ return self._notifications.get(notification_id)
+
+ def invoke_notification_action(self, notification_id: int, action: str):
+ """Invoke a named action on a notification
+
+ :param notification_id: the notification id of in which the action should be triggered
+ :type notification_id: int
+ :param action: the name of the action
+ :type action: str
+ """
+ return self.do_emit_bus_signal(
+ "ActionInvoked", GLib.Variant("(us)", (notification_id, action))
+ )
+
+ def remove_notification(self, notification_id: int):
+ """Remove a notification (without closing it) from the server
+
+ :param notification_id: the id of the notification
+ :type notification_id: int
+ """
+ return self.notification_removed(notification_id)
+
+ def close_notification(
+ self,
+ notification_id: int,
+ reason: NotificationCloseReason = NotificationCloseReason.DISMISSED_BY_USER,
+ ):
+ """Close a notification and remove it from the server
+
+ .. note::
+ Consider using `Notification.close` instead of this, this method is intended for internal usage and it is a subject to change.
+
+ :param notification_id: the id of the notification
+ :type notification_id: int
+ :param reason: the reason behind this notification begin closed, defaults to NotificationCloseReason.DISMISSED_BY_USER
+ :type reason: NotificationCloseReason, optional
+ """
+ return self.notification_closed(notification_id, reason)
+
+ def serialize(self) -> list[NotificationSerializedData]:
+ """Similar to `Notification.serialize` but it serializes all the notifications in the server instead
+
+ :return: a list with all the serialized notifications in this server
+ :rtype: list[NotificationSerializedData]
+ """
+ return [notif.serialize() for notif in self._notifications.values()]
+
+ def deserialize(self, data: list[NotificationSerializedData]):
+ """Load a list of serialized notifications data
+
+ :param data: the list with serialized notifications
+ :type data: list[NotificationSerializedData]
+ """
+ for notif_data in data:
+ self._notifications[notif_data["id"]] = Notification.deserialize(
+ data=notif_data,
+ on_closed=self.do_handle_notification_closed,
+ on_action_invoked=self.do_handle_notification_action_invoke,
+ )
+ self.notification_added(notif_data["id"])
+
+ # just to make sure we don't break newcoming notifications
+ # yet this doesn't fix "overwritten" notifications in the above loop
+ # all "overwritten" notifications are going to meet the VOID one day or another
+ # so this method _shall_ be used once the class is initialized
+ # TODO: a fix for this might be by randomizing the notification id
+ self._counter += max(self._notifications.keys() or (1,))
+ return
diff --git a/fabric/fabric/py.typed b/fabric/fabric/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/fabric/fabric/system_tray/__init__.py b/fabric/fabric/system_tray/__init__.py
new file mode 100644
index 0000000..1d1e056
--- /dev/null
+++ b/fabric/fabric/system_tray/__init__.py
@@ -0,0 +1,6 @@
+from .service import (
+ SystemTray,
+ SystemTrayItem,
+ SystemTrayItemPixmap,
+ SystemTrayItemToolTip,
+)
diff --git a/fabric/fabric/system_tray/service.py b/fabric/fabric/system_tray/service.py
new file mode 100644
index 0000000..4919cac
--- /dev/null
+++ b/fabric/fabric/system_tray/service.py
@@ -0,0 +1,607 @@
+import gi
+from loguru import logger
+from typing import NamedTuple, Literal, Any, cast
+from fabric.core.service import Service, Signal, Property
+from fabric.utils.helpers import load_dbus_xml, bulk_connect, get_enum_member
+
+gi.require_version("Gtk", "3.0")
+gi.require_version("DbusmenuGtk3", "0.4")
+from gi.repository import (
+ Gtk,
+ Gio,
+ Gdk,
+ GdkPixbuf,
+ DbusmenuGtk3,
+ GLib,
+)
+
+
+STATUS_NOTIFIER_WATCHER_BUS_NAME = "org.kde.StatusNotifierWatcher"
+STATUS_NOTIFIER_WATCHER_BUS_PATH = "/StatusNotifierWatcher"
+STATUS_NOTIFIER_WATCHER_BUS_IFACE_NODE = load_dbus_xml(
+ "../dbus_assets/org.kde.StatusNotifierWatcher.xml",
+)
+
+STATUS_NOTIFIER_ITEM_BUS_NAME = "org.kde.StatusNotifierItem"
+STATUS_NOTIFIER_ITEM_BUS_IFACE_NODE = load_dbus_xml(
+ "../dbus_assets/org.kde.StatusNotifierItem.xml"
+)
+
+
+class SystemTrayItemPixmap(
+ NamedTuple
+): # TODO: i don't think it's good to use NamedTuple, use dataclass instead
+ width: int | None = None
+ "icon width size in pixels"
+ height: int | None = None
+ "icon height size in pixels"
+ data: bytearray | None = None
+ "the actual data representing this icon"
+
+ def as_pixbuf(
+ self,
+ size: int | None = None,
+ resize_method: Literal[
+ "hyper",
+ "bilinear",
+ "nearest",
+ "tiles",
+ ] = "nearest",
+ ) -> GdkPixbuf.Pixbuf | None:
+ if not (self.width and self.height and self.data):
+ return None
+
+ data_bytearray = bytearray(self.data)
+
+ for i in range(0, 4 * self.width * self.height, 4):
+ alpha = data_bytearray[i]
+ data_bytearray[i] = data_bytearray[i + 1]
+ data_bytearray[i + 1] = data_bytearray[i + 2]
+ data_bytearray[i + 2] = data_bytearray[i + 3]
+ data_bytearray[i + 3] = alpha
+ pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(
+ GLib.Bytes.new(data_bytearray), # type: ignore
+ GdkPixbuf.Colorspace.RGB,
+ True,
+ 8,
+ self.width,
+ self.height,
+ self.width * 4,
+ )
+ return (
+ pixbuf.scale_simple(
+ size,
+ size,
+ {
+ "hyper": GdkPixbuf.InterpType.HYPER,
+ "bilinear": GdkPixbuf.InterpType.BILINEAR,
+ "nearest": GdkPixbuf.InterpType.NEAREST,
+ "tiles": GdkPixbuf.InterpType.TILES,
+ }.get(resize_method.lower(), GdkPixbuf.InterpType.NEAREST),
+ )
+ if size is not None and pixbuf is not None
+ else pixbuf
+ )
+
+
+class SystemTrayItemToolTip(NamedTuple):
+ icon_name: str | None = None
+ "free-desktop compliant name for an icon"
+ icon_pixmap: SystemTrayItemPixmap | None = None
+ "icon data as a pixmap"
+ title: str | None = None
+ "title for this tooltip"
+ description: str | None = None
+ "descriptive text for this tooltip. It can contain also a subset of the HTML markup language"
+
+
+class SystemTrayItem(Service):
+ @Signal
+ def changed(self) -> None: ...
+ @Signal
+ def removed(self) -> None: ...
+
+ def __init__(
+ self,
+ proxy: Gio.DBusProxy,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self._proxy = proxy
+ self._connection = proxy.get_connection()
+ self._bus_name = proxy.get_name()
+ self._bus_path = proxy.get_object_path()
+ self._identifier = self._bus_name + self._bus_path
+
+ self._menu: DbusmenuGtk3.Menu | None = self.do_create_menu(
+ self._proxy.get_name_owner(), self.menu_object_path
+ )
+ self._icon_theme: Gtk.IconTheme | None = None
+
+ bulk_connect(
+ self._proxy,
+ {
+ "g-signal": self.on_dbus_signal,
+ "g-properties-changed": lambda *_: self.changed(),
+ "notify::g-name-owner": lambda *_: (
+ self.removed() if not self._proxy.get_name_owner() else None
+ ),
+ },
+ )
+
+ def on_dbus_signal(self, _, __, signal_name: str, signal_args: tuple[str, ...]):
+ self.do_cache_proxy_properties()
+ signal_to_prop = {
+ f"New{dsig}": dsig.lower()
+ for dsig in [
+ "Title",
+ "Icon",
+ "AttentionIcon",
+ "OverlayIcon",
+ "ToolTip",
+ "Status",
+ ]
+ }.get(signal_name)
+ if not signal_to_prop:
+ return
+
+ self.notify(signal_to_prop)
+ return self.changed()
+
+ def get_preferred_icon_pixbuf(
+ self,
+ size: int | None = None,
+ resize_method: Literal[
+ "hyper",
+ "bilinear",
+ "nearest",
+ "tiles",
+ ]
+ | GdkPixbuf.InterpType = GdkPixbuf.InterpType.BILINEAR,
+ ) -> GdkPixbuf.Pixbuf | None:
+ icon_name = self.icon_name
+ attention_icon_name = self.attention_icon_name
+
+ icon_pixmap = self.icon_pixmap
+ attention_icon_pixmap = self.attention_icon_pixmap
+
+ if self.status == "NeedsAttention" and (
+ attention_icon_name is not None or attention_icon_pixmap is not None
+ ):
+ preferred_icon_name = attention_icon_name
+ preferred_icon_pixmap = attention_icon_pixmap
+ else:
+ preferred_icon_name = icon_name
+ preferred_icon_pixmap = icon_pixmap
+
+ icon_theme = self.icon_theme
+
+ icon_theme_sizes: list | None = (
+ icon_theme.get_icon_sizes(preferred_icon_name)
+ if preferred_icon_name is not None
+ else None
+ ) # type: ignore
+ icon_theme_sizes = [] if not icon_theme_sizes else icon_theme_sizes
+ icon_theme_sizes.append(size if size is not None else 24)
+
+ pixbuf = (
+ preferred_icon_pixmap.as_pixbuf()
+ if preferred_icon_pixmap is not None
+ else icon_theme.load_icon(
+ preferred_icon_name,
+ max(icon_theme_sizes),
+ Gtk.IconLookupFlags.FORCE_SIZE,
+ )
+ if preferred_icon_name is not None
+ else None
+ )
+ return (
+ pixbuf.scale_simple(
+ size,
+ size,
+ get_enum_member(
+ GdkPixbuf.InterpType,
+ resize_method,
+ default=GdkPixbuf.InterpType.NEAREST,
+ ),
+ )
+ if size is not None and pixbuf is not None
+ else pixbuf
+ )
+
+ # remote properties
+ @Property(int, "readable")
+ def id(self) -> int:
+ return self.do_get_proxy_property("Id")
+
+ @Property(str, "readable")
+ def identifier(self) -> str:
+ return self._identifier
+
+ @Property(str, "readable")
+ def title(self) -> str:
+ return self.do_get_proxy_property("Title")
+
+ @Property(str, "readable")
+ def status(self) -> str:
+ return self.do_get_proxy_property("Status")
+
+ @Property(str, "readable")
+ def category(self) -> str:
+ return self.do_get_proxy_property("Category")
+
+ @Property(int, "readable")
+ def window_id(self) -> int:
+ return self.do_get_proxy_property("WindowId")
+
+ @Property(str, "readable")
+ def icon_theme_path(self) -> str:
+ return self.do_get_proxy_property("IconThemePath")
+
+ @Property(Gtk.IconTheme, "readable")
+ def icon_theme(self) -> Gtk.IconTheme:
+ if not self._icon_theme:
+ self._icon_theme = Gtk.IconTheme()
+ search_path = self.get_icon_theme_path()
+ self._icon_theme.set_search_path([search_path]) if search_path not in (
+ None,
+ "",
+ ) else None
+ return self._icon_theme
+
+ @Property(str, "readable")
+ def icon_name(self) -> str:
+ return self.do_get_proxy_property("IconName")
+
+ @Property(SystemTrayItemPixmap, "readable")
+ def icon_pixmap(self) -> SystemTrayItemPixmap:
+ return self.do_extract_pixmap(self.do_get_proxy_property("IconPixmap"))
+
+ @Property(str, "readable")
+ def overlay_icon_name(self) -> str:
+ return self.do_get_proxy_property("OverlayIconName")
+
+ @Property(SystemTrayItemPixmap, "readable")
+ def overlay_icon_pixmap(self) -> SystemTrayItemPixmap:
+ return self.do_extract_pixmap(self.do_get_proxy_property("OverlayIconPixmap"))
+
+ @Property(str, "readable")
+ def attention_icon_name(self) -> str:
+ return self.do_get_proxy_property("AttentionIconName")
+
+ @Property(SystemTrayItemPixmap, "readable")
+ def attention_icon_pixmap(self) -> SystemTrayItemPixmap:
+ return self.do_extract_pixmap(self.do_get_proxy_property("AttentionIconPixmap"))
+
+ @Property(SystemTrayItemToolTip, "readable")
+ def tooltip(self) -> SystemTrayItemToolTip:
+ return self.do_unpack_tooltip(self.do_get_proxy_property("ToolTip"))
+
+ @Property(bool, "readable", default_value=False)
+ def is_menu(self) -> bool:
+ return self.do_get_proxy_property("ItemIsMenu")
+
+ @Property(str, "readable")
+ def menu_object_path(self) -> str:
+ return self.do_get_proxy_property("Menu")
+
+ @Property(DbusmenuGtk3.Menu, "readable")
+ def menu(self) -> DbusmenuGtk3.Menu:
+ if self._menu is not None:
+ return self._menu
+ self._menu = self.do_create_menu(
+ self._proxy.get_name_owner(), self.get_menu_object_path()
+ )
+ return self._menu # type: ignore
+
+ # remote methods
+ def context_menu(self, x: int, y: int) -> None:
+ """to open a server-side context menu"""
+ return self._proxy.ContextMenu("(ii)", x, y)
+
+ def activate(self, x: int, y: int) -> None:
+ return self._proxy.Activate("(ii)", x, y)
+
+ def secondary_activate(self, x: int, y: int) -> None:
+ return self._proxy.SecondaryActivate("(ii)", x, y)
+
+ def invoke_menu_for_event(self, event: Gdk.Event) -> None:
+ menu = self.get_menu()
+ return (
+ menu.popup_at_pointer(event)
+ if menu is not None
+ else self.context_menu_for_event(event)
+ )
+
+ def scroll(
+ self, delta: int, orientation: Literal["vertical", "horizontal"]
+ ) -> None:
+ return self._proxy.Scroll("(is)", delta, orientation)
+
+ # event methods
+ def context_menu_for_event(self, event: Gdk.EventAny) -> None:
+ return self.context_menu(*event.get_root_coords())
+
+ def activate_for_event(self, event: Gdk.EventAny) -> None:
+ return self.activate(*event.get_root_coords())
+
+ def secondary_activate_for_event(self, event: Gdk.EventAny) -> None:
+ return self.secondary_activate(*event.get_root_coords())
+
+ def scroll_for_event(self, event: Gdk.EventScroll) -> None:
+ direction = (
+ "vertical" if event.direction == 0 or event.direction == 1 else "horizontal"
+ )
+ delta: int = event.delta_y if direction == "vertical" else event.delta_x
+ return self.scroll(delta, direction)
+
+ # privates
+ def do_get_proxy_property(self, property_name: str) -> Any:
+ value = self._proxy.get_cached_property(property_name)
+ if value is None:
+ return None
+ return value.unpack()
+
+ def do_extract_pixmap(
+ self, pixmaps: list[tuple[int, int, bytearray]]
+ ) -> SystemTrayItemPixmap | None:
+ # only use the biggest icon and ignore all others
+ if not pixmaps: # to handle both None and []
+ return None
+
+ sorted_pixmap = sorted(pixmaps, key=lambda x: x[0])
+ return (
+ SystemTrayItemPixmap(*sorted_pixmap[-1])
+ if len(sorted_pixmap) >= 1
+ else None
+ )
+
+ def do_unpack_tooltip(
+ self, tooltip: tuple[str, list[tuple[int, int, bytearray]], str, str] | None
+ ) -> SystemTrayItemToolTip:
+ if not tooltip or not len(tooltip) == 4:
+ return SystemTrayItemToolTip()
+ return SystemTrayItemToolTip(
+ tooltip[0],
+ self.do_extract_pixmap(tooltip[1]),
+ tooltip[2],
+ tooltip[3],
+ ) # this should be fine
+
+ def do_create_menu(self, bus_name: str, bus_path: str) -> DbusmenuGtk3.Menu | None:
+ return (
+ DbusmenuGtk3.Menu().new(bus_name, bus_path)
+ if bus_path not in (None, "")
+ else None
+ )
+
+ def do_cache_proxy_properties(self) -> None:
+ return self._connection.call( # "async"
+ self._proxy.get_name(),
+ self._proxy.get_object_path(),
+ "org.freedesktop.DBus.Properties",
+ "GetAll",
+ GLib.Variant("(s)", [self._proxy.get_interface_name()]),
+ GLib.VariantType("(a{sv})"),
+ Gio.DBusCallFlags.NONE,
+ -1, # no timeout
+ None,
+ self.do_cache_proxy_properties_finish,
+ )
+
+ def do_cache_proxy_properties_finish(self, _, result: Gio.AsyncResult) -> None:
+ try:
+ props_var: GLib.Variant = self._connection.call_finish(result)
+ if not props_var:
+ raise RuntimeError("can't get the properties variant")
+ except Exception as e:
+ return logger.warning(
+ f"[SystemTray][Item] can't update properties for item with identifier {self._identifier} ({e})"
+ )
+
+ def unpack_properties(variant: GLib.Variant) -> dict:
+ res = {}
+ variant = variant.get_child_value(0)
+ if variant.get_type_string().startswith("a{"):
+ for i in range(variant.n_children()):
+ v = variant.get_child_value(i)
+ res[v.get_child_value(0).unpack()] = v.get_child_value(1)
+ return res
+
+ for prop_name, prop_value in unpack_properties(props_var).items():
+ prop_name: str
+ prop_value: GLib.Variant
+ self._proxy.set_cached_property(prop_name, prop_value.get_variant())
+ return self.changed()
+
+
+class SystemTray(Service):
+ @Signal
+ def changed(self) -> None: ...
+ @Signal
+ def item_added(self, item_identifier: str) -> None: ...
+ @Signal
+ def item_removed(self, item_identifier: str) -> None: ...
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._items: dict[str, SystemTrayItem] = {}
+ self._connection: Gio.DBusConnection | None = None
+
+ # all aboard...
+ self.do_register()
+
+ @Property(dict[str, SystemTrayItem], "readable")
+ def items(self) -> dict[str, SystemTrayItem]:
+ return self._items
+
+ def add_item(self, item: SystemTrayItem) -> None:
+ self._items[item._identifier] = item
+ self.do_notify_registered_item(item._identifier)
+ return
+
+ def remove_item(self, item: SystemTrayItem) -> None:
+ try:
+ self._items.pop(item._identifier)
+ self.do_notify_unregistered_item(item._identifier)
+ except:
+ logger.warning(
+ f"[SystemTray] can't remove tray item with identifier {item._identifier}"
+ )
+ return
+
+ def do_register(self) -> int: # the bus id
+ return Gio.bus_own_name(
+ Gio.BusType.SESSION,
+ STATUS_NOTIFIER_WATCHER_BUS_NAME,
+ Gio.BusNameOwnerFlags.NONE,
+ self.on_bus_acquired,
+ None,
+ lambda *_: logger.warning(
+ "[SystemTray] can't own the DBus name, another bar is probably running"
+ ),
+ )
+
+ def on_bus_acquired(
+ self, conn: Gio.DBusConnection, name: str, user_data: object = None
+ ) -> None:
+ self._connection = conn
+ # we now own the name
+ for interface in STATUS_NOTIFIER_WATCHER_BUS_IFACE_NODE.interfaces:
+ cast(Gio.DBusInterface, interface)
+ if interface.name == name:
+ conn.register_object(
+ STATUS_NOTIFIER_WATCHER_BUS_PATH,
+ interface,
+ self.do_handle_bus_call, # type: ignore
+ )
+ return
+
+ def do_handle_bus_call(
+ self,
+ conn: Gio.DBusConnection,
+ sender: str,
+ path: str,
+ interface: str,
+ target: str,
+ params: tuple,
+ invocation: Gio.DBusMethodInvocation,
+ user_data: object = None,
+ ) -> None:
+ match target:
+ case "Get":
+ prop_name = params[1] if len(params) >= 1 else None
+ match prop_name:
+ case "ProtocolVersion":
+ invocation.return_value(
+ GLib.Variant("(v)", (GLib.Variant("i", 1),))
+ )
+ case "IsStatusNotifierHostRegistered":
+ invocation.return_value(
+ GLib.Variant("(v)", (GLib.Variant("b", True),))
+ )
+ case "RegisteredStatusNotifierItems":
+ invocation.return_value(
+ GLib.Variant(
+ "(v)", (GLib.Variant("as", self._items.keys()),)
+ ),
+ )
+ case _:
+ invocation.return_value(None)
+ case "GetAll":
+ all_properties = {
+ "ProtocolVersion": GLib.Variant("i", 1),
+ "IsStatusNotifierHostRegistered": GLib.Variant("b", True),
+ "RegisteredStatusNotifierItems": GLib.Variant(
+ "as", self._items.keys()
+ ),
+ }
+
+ invocation.return_value(GLib.Variant("(a{sv})", (all_properties,)))
+ case "RegisterStatusNotifierItem":
+ self.do_create_item(sender, params[0] if len(params) >= 1 else "")
+ invocation.return_value(None)
+
+ return conn.flush()
+
+ def do_create_item(self, bus_name: str, bus_path: str) -> None:
+ # phase 1, validate
+ if (
+ bus_name is None
+ or bus_path is None
+ or not isinstance(bus_name, str)
+ or not isinstance(bus_path, str)
+ or self.get_items().get(bus_name + bus_path) is not None
+ ):
+ return
+
+ if not bus_path.startswith("/"):
+ bus_path = "/StatusNotifierItem"
+
+ return self.do_acquire_item_proxy(bus_name, bus_path)
+
+ def do_acquire_item_proxy(self, bus_name: str, bus_path: str) -> None:
+ # phase 2, get the proxy
+
+ # some remote objects has a huge props size
+ # so loading them in a async way is an ideal
+ return Gio.DBusProxy.new_for_bus(
+ Gio.BusType.SESSION,
+ Gio.DBusProxyFlags.NONE,
+ STATUS_NOTIFIER_ITEM_BUS_IFACE_NODE.interfaces[0],
+ bus_name,
+ bus_path,
+ STATUS_NOTIFIER_ITEM_BUS_NAME,
+ None,
+ lambda *args: self.do_acquire_item_proxy_finish(bus_name, bus_path, *args),
+ None,
+ )
+
+ def do_acquire_item_proxy_finish(
+ self,
+ bus_name: str,
+ bus_path: str,
+ proxy: Gio.DBusProxy,
+ result: Gio.AsyncResult,
+ *args,
+ ) -> None:
+ # phase 3, we might have gotten a proxy, if so create a new item
+ proxy = proxy.new_for_bus_finish(result)
+ if not proxy:
+ return logger.warning(
+ f"[SystemTray] can't acquire proxy object for tray item with identifier {bus_name + bus_path}"
+ )
+
+ item = SystemTrayItem(proxy)
+ item.removed.connect(lambda *args: self.remove_item(item))
+
+ # all aboard...
+ self.add_item(item)
+ return
+
+ def do_emit_bus_signal(self, signal_name: str, params: GLib.Variant) -> None:
+ self._connection.emit_signal(
+ None,
+ STATUS_NOTIFIER_WATCHER_BUS_PATH,
+ STATUS_NOTIFIER_WATCHER_BUS_NAME,
+ signal_name,
+ params,
+ ) if self._connection is not None else None
+ return
+
+ def do_notify_registered_item(self, identifier: str) -> None:
+ self.do_emit_bus_signal(
+ "StatusNotifierItemRegistered", GLib.Variant("(s)", (identifier,))
+ )
+ self.item_added(identifier)
+ self.changed()
+ return
+
+ def do_notify_unregistered_item(self, identifier: str) -> None:
+ self.changed()
+ self.item_removed(identifier)
+ self.do_emit_bus_signal(
+ "StatusNotifierItemUnregistered",
+ GLib.Variant("(s)", (identifier,)),
+ )
+ return
diff --git a/fabric/fabric/system_tray/widgets.py b/fabric/fabric/system_tray/widgets.py
new file mode 100644
index 0000000..955e7c4
--- /dev/null
+++ b/fabric/fabric/system_tray/widgets.py
@@ -0,0 +1,94 @@
+from loguru import logger
+from fabric.widgets.box import Box
+from fabric.widgets.image import Image
+from fabric.widgets.button import Button
+from fabric.system_tray.service import (
+ SystemTray as SystemTrayService,
+ SystemTrayItem as SystemTrayItemService,
+)
+
+
+watcher: SystemTrayService | None = None
+
+
+def get_tray_watcher() -> SystemTrayService:
+ global watcher
+ if not watcher:
+ watcher = SystemTrayService()
+
+ return watcher
+
+
+class SystemTrayItem(Button):
+ def __init__(self, item: SystemTrayItemService, icon_size: int, **kwargs):
+ super().__init__(**kwargs)
+ self._item = item
+ self._icon_size = icon_size
+ self._image = Image()
+ self.set_image(self._image)
+
+ self._item.changed.connect(self.do_update_properties)
+ self.connect("button-press-event", self.on_clicked)
+
+ self.do_update_properties()
+
+ def do_update_properties(self, *_):
+ pixbuf = self._item.get_preferred_icon_pixbuf(self._icon_size)
+ if pixbuf is not None:
+ self._image.set_from_pixbuf(pixbuf)
+ else:
+ self._image.set_from_icon_name("image-missing", self._icon_size)
+
+ tooltip = self._item.tooltip
+ self.set_tooltip_markup(
+ tooltip.description or tooltip.title or self._item.title.title()
+ if self._item.title
+ else "Unknown"
+ )
+ return
+
+ def on_clicked(self, _, event):
+ match event.button:
+ case 1:
+ try:
+ self._item.activate_for_event(event)
+ except Exception as e:
+ logger.warning(
+ f"[SystemTrayItem] can't activate item with name {self._item.title or self._item.identifier} ({e})"
+ )
+ case 3:
+ self._item.invoke_menu_for_event(event)
+ return
+
+
+class SystemTray(Box):
+ def __init__(self, icon_size: int = 24, **kwargs):
+ super().__init__(**kwargs)
+ self._icon_size = icon_size
+ self._items: dict[str, SystemTrayItem] = {}
+
+ self._watcher = get_tray_watcher()
+ self._watcher.connect("item-added", self.on_item_added)
+ self._watcher.connect("item-removed", self.on_item_removed)
+
+ def on_item_added(self, _, item_identifier: str):
+ item = self._watcher.items.get(item_identifier)
+ if not item:
+ return
+
+ item_button = SystemTrayItem(item, self._icon_size)
+ self.add(item_button)
+ self._items[item.identifier] = item_button
+ return
+
+ def on_item_removed(self, _, item_identifier):
+ item_button = self._items.get(item_identifier)
+ if not item_button:
+ return
+
+ self.remove(item_button)
+ self._items.pop(item_identifier)
+ return
+
+
+__all__ = ["SystemTray", "SystemTrayItem", "get_tray_watcher"]
diff --git a/fabric/fabric/utils/__init__.py b/fabric/fabric/utils/__init__.py
new file mode 100644
index 0000000..b9211a7
--- /dev/null
+++ b/fabric/fabric/utils/__init__.py
@@ -0,0 +1 @@
+from .helpers import *
diff --git a/fabric/fabric/utils/helpers.py b/fabric/fabric/utils/helpers.py
new file mode 100644
index 0000000..1dbf896
--- /dev/null
+++ b/fabric/fabric/utils/helpers.py
@@ -0,0 +1,947 @@
+import gi
+import re
+import os
+import time
+import math
+import cairo
+import shlex
+import string
+import random
+import inspect
+from enum import Enum
+from loguru import logger
+from functools import wraps
+from dataclasses import dataclass
+from collections.abc import Callable, Iterable, Generator
+from typing import (
+ cast,
+ Literal,
+ NamedTuple,
+ Union,
+ TypeAlias,
+ Generic,
+ TypeVar,
+ ParamSpec,
+ Any,
+)
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk, GdkPixbuf, GObject, Gio, GLib
+
+P = ParamSpec("P")
+T = TypeVar("T")
+E = TypeVar("E", bound=Enum)
+MISSING = TypeVar("MISSING")
+Number: TypeAlias = int | float
+
+
+class __DeprecationHook__:
+ def __init__(self, deprecated_to_replacement: dict[str, str]):
+ self.lookup_table = deprecated_to_replacement
+
+ def __call__(self):
+ if replacement := self.lookup_table.get(
+ (caller := inspect.currentframe().f_back.f_code.co_name), # type: ignore
+ None,
+ ):
+ return logger.warning(
+ f"the function `{caller}` is deprecated and will be removed in future versions of Fabric, consider using `{replacement}` instead"
+ )
+ return
+
+
+__deprecation_table = __DeprecationHook__(
+ {
+ "idlify": "idle_add",
+ "set_stylesheet_from_string": "Application.add_stylesheet_from_string",
+ "set_stylesheet_from_file": "Application.add_stylesheet_from_file",
+ "get_gdk_rgba": "Gdk.RGBA.parse OR parse_color",
+ }
+)
+
+
+class PixbufUtils:
+ @staticmethod
+ def from_cairo_surface(
+ surface: cairo.ImageSurface,
+ x_offset: int = 0,
+ y_offset: int = 0,
+ target_width: int | None = None,
+ target_height: int | None = None,
+ ) -> GdkPixbuf.Pixbuf:
+ return Gdk.pixbuf_get_from_surface(
+ surface, # type: ignore
+ x_offset,
+ y_offset,
+ target_width or surface.get_width(),
+ target_height or surface.get_height(),
+ )
+
+ @staticmethod
+ def set_cairo_source(
+ pixbuf: GdkPixbuf.Pixbuf,
+ cr: cairo.Context,
+ x_offset: int = 0,
+ y_offset: int = 0,
+ ):
+ return Gdk.cairo_set_source_pixbuf(cr, pixbuf, x_offset, y_offset) # type: ignore
+
+ @staticmethod
+ def rotate(pixbuf: GdkPixbuf.Pixbuf, angle: float) -> GdkPixbuf.Pixbuf:
+ """return a rotated version of the given `GdkPixbuf.Pixbuf` to a given angle
+
+ :param pixbuf: the input pixbuf
+ :type pixbuf: GdkPixbuf.Pixbuf
+ :param angle: the desired rotation angle in degrees
+ :type angle: float
+ :return: the newly rotated pixbuf
+ :rtype: GdkPixbuf.Pixbuf
+ """
+ r = math.radians(angle)
+ w, h = pixbuf.get_width(), pixbuf.get_height()
+
+ nw = round(abs(w * math.cos(r)) + abs(h * math.sin(r)))
+ nh = round(abs(w * math.sin(r)) + abs(h * math.cos(r)))
+
+ surface = cairo.ImageSurface(cairo.Format.ARGB32, nw, nh)
+ cr = cairo.Context(surface)
+
+ cr.translate(nw / 2, nh / 2)
+ cr.rotate(r)
+ PixbufUtils.set_cairo_source(pixbuf, cr, round(-w / 2), round(-h / 2))
+ cr.paint()
+
+ return PixbufUtils.from_cairo_surface(
+ surface, target_width=nw, target_height=nh
+ )
+
+
+class FormattedString:
+ """simple string formatter made to be baked mid-runtime"""
+
+ class FormatDict(dict):
+ def __init__(self, *args, **kwargs):
+ super(FormattedString.FormatDict, self).__init__(*args, **kwargs)
+
+ def __missing__(self, key):
+ try:
+ rkey = eval(key, globals(), self)
+ except Exception as e:
+ logger.warning(
+ f"[FormattedString] couldn't format string expression ({key}), raised exception is\n {str(e)}"
+ )
+ rkey = key.join("{}")
+ return rkey
+
+ def __init__(self, string: str, **kwargs) -> None:
+ self.__string__ = string
+ self.__format_map__ = kwargs
+
+ def __call__(self, **kwargs) -> str:
+ return self.format(**kwargs)
+
+ def format(self, **kwargs) -> str:
+ return self.__string__.format_map(
+ FormattedString.FormatDict(self.__format_map__ | kwargs)
+ )
+
+
+@dataclass(init=False)
+class DesktopApp:
+ name: str
+ generic_name: str | None
+ display_name: str | None
+ description: str | None
+ window_class: str | None
+ executable: str | None
+ command_line: str | None
+ icon: (
+ Gio.Icon
+ | Gio.ThemedIcon
+ | Gio.FileIcon
+ | Gio.LoadableIcon
+ | Gio.EmblemedIcon
+ | None
+ )
+ icon_name: str | None
+ hidden: bool
+
+ def __init__(
+ self, app: Gio.DesktopAppInfo, icon_theme: Gtk.IconTheme | None = None
+ ):
+ self._app: Gio.DesktopAppInfo = app
+ self._icon_theme = icon_theme or Gtk.IconTheme.get_default()
+ self._pixbuf: GdkPixbuf.Pixbuf | None = None
+ self.name = app.get_name() # type: ignore
+ self.generic_name = app.get_generic_name() # type: ignore
+ self.display_name = app.get_display_name() # type: ignore
+ self.description = app.get_description() # type: ignore
+ self.window_class = app.get_startup_wm_class() # type: ignore
+ self.executable = app.get_executable() # type: ignore
+ self.command_line = app.get_commandline() # type: ignore
+ self.icon = app.get_icon() # type: ignore
+ self.icon_name = self.icon.to_string() if self.icon is not None else None # type: ignore
+ self.hidden = app.get_is_hidden()
+
+ def launch(self):
+ return self._app.launch() # type: ignore
+
+ def get_icon_pixbuf(
+ self,
+ size: int = 48,
+ default_icon: str | None = "image-missing",
+ flags: Gtk.IconLookupFlags = Gtk.IconLookupFlags.FORCE_REGULAR
+ | Gtk.IconLookupFlags.FORCE_SIZE, # type: ignore
+ ) -> GdkPixbuf.Pixbuf | None:
+ """
+ get a pixbuf from the icon (if any)
+
+ :param size: the size of the icon, defaults to 48
+ :type size: int, optional
+ :param default_icon: the name of the default icon. pass None if you want to receive None upon failing, defaults to "image-missing"
+ :type default_icon: str | None, optional
+ :param flags: the Gtk.IconLookupFlags to use when fetching the icon
+ :type flags: Gtk.IconLookupFlags, defaults to (Gtk.IconLookupFlags.FORCE_REGULAR | Gtk.IconLookupFlags.FORCE_SIZE), optional
+ :return: the pixbuf
+ :rtype: GdkPixbuf.Pixbuf | None
+ """
+ if self._pixbuf:
+ return self._pixbuf # already loaded
+ try:
+ if not self.icon_name:
+ raise
+ self.icon
+ self._pixbuf = self._icon_theme.load_icon(
+ self.icon_name,
+ size,
+ flags,
+ )
+ except Exception:
+ self._pixbuf = (
+ self._icon_theme.load_icon(default_icon, size, flags)
+ if default_icon is not None
+ else None
+ )
+
+ return self._pixbuf
+
+
+def get_desktop_applications(include_hidden: bool = False) -> list[DesktopApp]:
+ """
+ get a list of all desktop applications
+ this might be useful for writing application launchers
+
+ :param include_hidden: whether to include applications unintended to be visible to normal users, defaults to false
+ :type include_hidden: bool, optional
+ :return: a list of all desktop applications
+ :rtype: list[DesktopApp]
+ """
+ icon_theme = Gtk.IconTheme.get_default()
+ return [
+ DesktopApp(app, icon_theme)
+ for app in Gio.DesktopAppInfo.get_all()
+ if include_hidden or app.should_show()
+ ]
+
+
+def parse_color(color: str | Iterable[Number]) -> Gdk.RGBA:
+ """parse a serialized color data over to a `Gdk.RGBA` object
+
+ :param color: the color data, example of an iterable color; `(255, 255, 255) / (255, 255, 255, 255)`, for a list of parseable string formats head over to https://docs.gtk.org/gdk3/method.RGBA.parse.html
+ :type color: str | Iterable[Number]
+ :raises ValueError: if the passed in color data is unparsable
+ :return: the newly created `Gdk.RGBA` (alpha is set to opaque if the passed in color data is RGB only)
+ :rtype: Gdk.RGBA
+ """
+ if (
+ isinstance(color, (tuple, list))
+ and (color_len := len(color)) >= 3
+ and color_len <= 4
+ ):
+ return Gdk.RGBA(*[c / 255.0 for c in color])
+ elif isinstance(color, str):
+ rgba = Gdk.RGBA()
+ if rgba.parse(color):
+ return rgba
+ raise ValueError(f"{color} is an invalid color format")
+
+
+def get_gdk_rgba(color: str | Iterable[Number]) -> Gdk.RGBA:
+ __deprecation_table()
+ return parse_color(color)
+
+
+def compile_css(
+ css_string: str,
+ base_path: str = ".",
+ exposed_functions: dict[str, Callable] | Iterable[Callable] | None = None,
+) -> str:
+ """
+ preprocess and transpile a CSS string to GTK's CSS syntax.
+
+ supports transpiling web-css like variables over to GTK's `@define-color` syntax.
+
+ also supports having CSS macros. syntax example:
+ .. code-block:: css
+ /* define a macro */
+ @define my-macro(--arg-1, --arg-2) {
+ /* CSS body goes here. example body.. */
+ color: --arg-1;
+ background-color: --arg-2;
+ }
+
+ #my-widget {
+ @apply my-macro(red, blue);
+ /* compiles to
+ color: --arg-1;
+ background-color: --arg-2;
+ */
+ }
+
+ **Note:** this function relies on a series of regular expressions for its processing, which may lead to potential issues in certain edge cases.
+
+ :param css_string: the input CSS as a string.
+ :type css_string: str
+ :param base_path: for `@import` statements, used for relative imports.
+ :type base_path: str, optional
+ :param exposed_functions: a dictionary of macro functions or an iterable of callable functions to use as extra macros. if a dictionary is provided, the keys are macro names, and the values are the corresponding functions.
+ :type exposed_functions: dict[str, Callable] | Iterable[Callable] | None, optional
+ :return: the compiled CSS string converted to GTK's CSS syntax.
+ :rtype: str
+ """
+
+ import_pattern = re.compile(r'@import\s+(?:url\()?["\']?([^"\')]+)["\']?\)?\s*;')
+
+ vars_selector_pattern = re.compile(r":vars\s*{\s*([^}]+)\s*}")
+ vars_declaration_pattern = re.compile(r"--([\w-]+)\s*:\s*([^;]+)\s*;")
+ vars_reference_pattern = re.compile(r"var\(--([\w-]+)\)")
+
+ constant_pattern = re.compile(r"@define\s+([\w-]+)\s+([^;]+);")
+ constant_apply_pattern = re.compile(r"apply\(([\w-]+)\)")
+
+ macro_pattern = re.compile(r"@define\s+([\w-]+)\(([^)]*)\)\s*{\s*([^}]+)\s*}")
+ macro_apply_pattern = re.compile(r"@apply\s+([\w-]+)\(([^)]*)\)\s*;?")
+
+ functions_map: dict[str, Callable] = (
+ {}
+ if not exposed_functions
+ else (
+ {
+ snake_case_to_kebab_case(func.__name__): func
+ for func in (
+ exposed_functions
+ if not isinstance(exposed_functions, Callable)
+ else (exposed_functions,)
+ )
+ }
+ if isinstance(exposed_functions, (list, tuple, Callable))
+ else exposed_functions
+ )
+ ) # type: ignore
+
+ def resolve_imports(css_content: str) -> str:
+ def import_replacement(match: re.Match) -> str:
+ file_path = match.group(1)
+ full_path = os.path.join(base_path, file_path)
+
+ try:
+ with open(full_path, "r") as imported_file:
+ imported_content = imported_file.read()
+ return resolve_imports(imported_content)
+ except Exception as e:
+ logger.warning(
+ f"[FASS] couldn't find the imported file: {full_path}, Error: {e}"
+ )
+ return f"/* couldn't import file: {file_path} */"
+
+ return import_pattern.sub(import_replacement, css_content)
+
+ # resolve @import statements before passing over to the preprocessor
+ css_output = resolve_imports(css_string)
+
+ # color variables
+ match = vars_selector_pattern.search(css_output)
+ css_output = (
+ f"{match.group(1)}\n\n{css_output.replace(match.group(0), '')}"
+ if match
+ else css_output
+ )
+
+ # this could be preprocessed as the original value not (a translation to Gtk's syntax)
+ css_output = vars_declaration_pattern.sub(
+ lambda m: f"@define-color {m.group(1)} {m.group(2)};", css_output
+ )
+ css_output = vars_reference_pattern.sub(r"@\1", css_output)
+
+ # preprocessing
+ constants: dict[str, str] = {
+ m.group(1): m.group(2) for m in constant_pattern.finditer(css_output)
+ }
+ css_output = constant_pattern.sub("", css_output)
+ css_output = constant_apply_pattern.sub(
+ lambda m: constants.get(m.group(1), m.group(0)), css_output
+ )
+
+ # keys are macro names.
+ # tuple's first item is a list of arguments
+ # and last item is the macro's body
+ macros: dict[str, tuple[tuple[str, ...], str]] = {
+ cast(str, m.group(1)): (
+ tuple(args_group.split(",")) if (args_group := m.group(2)) else (),
+ m.group(3).strip(),
+ )
+ for m in macro_pattern.finditer(css_output)
+ }
+ css_output = macro_pattern.sub("", css_output)
+
+ def apply_macro_replacement(match: re.Match) -> str:
+ macro_name = match.group(1)
+ macro_func = functions_map.get(macro_name, None)
+ macro_args, macro_body = macros.get(macro_name, (None, ""))
+ if not macro_args and not macro_body and not macro_func:
+ logger.warning(f"[FASS] couldn't find a macro with name {macro_name}")
+ return match.group(0) # like nothing has happened
+
+ logger.info(f"[FASS] applying a macro with name {macro_name}")
+
+ # passed in parameters
+ passed_params: list[str] = (
+ [arg_name.strip() for arg_name in args_group.split(",")]
+ if (args_group := match.group(2))
+ else []
+ )
+
+ if macro_func:
+ return macro_func(*passed_params)
+
+ for param, arg in zip(cast(tuple[str, ...], macro_args), passed_params):
+ macro_body = macro_body.replace(param.strip(), arg)
+
+ return macro_body
+
+ css_output = macro_apply_pattern.sub(apply_macro_replacement, css_output)
+
+ # clean up
+ css_output = re.sub(r"\n\s*\n", "\n", css_output).strip()
+
+ return css_output
+
+
+def bulk_replace(
+ string: str,
+ patterns: Iterable[str],
+ replacements: Iterable[str],
+ regex: bool = False,
+) -> str:
+ """
+ Replaces occurrences of multiple patterns in a string with corresponding replacements.
+
+ :param string: the input string in which replacements will be made.
+ :type string: str
+ :param patterns: the patterns to be replaced, this can be a list of strings or a list of regular expressions.
+ :type patterns: Iterable[str]
+ :param replacements: the replacements for each text.
+ :type replacements: Iterable[str]
+ :param regex: Whether to interpret the patterns as regular expressions. Defaults to False.
+ :type regex: bool, optional
+
+ :return: the string with replacements made.
+ :rtype: str
+
+ :raises ValueError: If the lengths of patterns and replacements are not the same.
+ """
+ if not (
+ isinstance(patterns, (tuple, list)) and isinstance(replacements, (tuple, list))
+ ):
+ return ""
+
+ if len(patterns) != len(replacements):
+ raise ValueError("patterns and replacements must be the same length.")
+
+ for text, replacement in zip(patterns, replacements):
+ if regex:
+ string = re.sub(text, replacement, string)
+ else:
+ string = string.replace(text, replacement)
+
+ return string
+
+
+def bulk_connect(
+ connectable: GObject.Object, mapping: dict[str, Callable]
+) -> tuple[int, ...]:
+ """connects a list of signals to a list of callbacks to an object
+
+ :param connectable: the object to connect the signals to
+ :type connectable: GObject.Object
+ :param mapping: the mapping of signals to callbacks, example: `{"signal-name": lambda *args: ...}`
+ :type mapping: dict[str, Callable]
+ :rtype: tuple[int, ...]
+ """
+
+ return tuple(
+ connectable.connect(signal, callback) # type: ignore
+ for signal, callback in mapping.items()
+ )
+
+
+def bulk_disconnect(
+ disconnectable: GObject.Object,
+ signals_or_funcs: Iterable[Union[str, Callable]],
+) -> tuple[int, ...]:
+ """does the opposite of bulk_connect
+
+ :param disconnectable: the object to disconnect the signals from
+ :type disconnectable: GObject.Object | object
+ :param signals_or_funcs: iterable of signals/callbacks to disconnect
+ :type signals: Iterable[Union[str, Callable]]
+ :return: a list of return values from the `disconnect` function
+ :rtype: tuple[int]
+ """
+
+ def disconnect(signal_or_func) -> int:
+ if callable(signal_or_func):
+ return disconnectable.disconnect_by_func(signal_or_func) # type: ignore
+ return disconnectable.disconnect(signal_or_func) # type: ignore
+
+ return tuple(disconnect(x) for x in signals_or_funcs)
+
+
+def clamp(value: Number, min_value: Number, max_value: Number) -> Number:
+ """
+ clamp a value between a minimum and maximum value
+
+ :param value: the value to be clamped
+ :type value: float or int
+ :param min_value: the minimum value to clamp to
+ :type min_value: float or int
+ :param max_value: the maximum value to clamp to
+ :type max_value: float or int
+ :return: the clamped value
+ :rtype: float or int
+ """
+ return max(min(value, max_value), min_value)
+
+
+def extract_css_values(css_string: str) -> tuple[int, int, int, int]:
+ """
+ extracs and return a tuple of four CSS values from a given CSS string.
+
+ :param css_string: the CSS string to extract the values from.
+ :type css_string: str
+ :return: a tuple of four integers representing the extracted CSS values. If the CSS string
+ does not contain enough values, the missing values are filled with zeros.
+ :rtype: tuple
+ """
+ pattern = re.compile(
+ r"(-?\d+)(?:px)?(?:\s+(-?\d+)(?:px)?(?:\s+(-?\d+)(?:px)?(?:\s+(-?\d+)(?:px)?)?)?)?"
+ )
+ matches = pattern.match(css_string)
+ default_values = (0, 0, 0, 0)
+ if matches:
+ values = [int(val) if val else 0 for val in matches.groups()]
+ values.extend([values[-1]] * (4 - len(values)))
+ return tuple(values) # type: ignore
+ else:
+ return default_values
+
+
+def monitor_file(
+ path: str,
+ *callbacks: Callable,
+ flags: Literal[
+ "none",
+ "watch-mounts",
+ "send-moved",
+ "watch-hard-links",
+ "watch-moves",
+ ]
+ | Gio.FileMonitorFlags = Gio.FileMonitorFlags.NONE,
+ initial_call: bool = False,
+) -> Gio.FileMonitor:
+ """
+ Monitor a specific file or directory for changes...
+
+ :param path: path to the desired file or directory
+ :type path: str
+ :param callbacks: list of functions each assigned directly to the `"changed"` signal of the monitor
+ :type callbacks: Callable
+ :param flags: flags to configure the file monitor. Defaults to None.
+ :type flags: Literal["none", "watch-mounts", "send-moved", "watch-hard-links", "watch-moves"], optional
+ :param initial_call: whether should the given callbacks get called upon registering. Defaults to False
+ :type initial_call: bool, optional
+ :return: the monitor for the specified path
+ :rtype: Gio.FileMonitor
+ """
+ file: Gio.File = Gio.File.new_for_uri( # type: ignore
+ ("file://" + os.path.expanduser(path)) if "://" not in path else path
+ )
+ monitor = file.monitor(
+ get_enum_member(Gio.FileMonitorFlags, flags, default=Gio.FileMonitorFlags.NONE)
+ )
+
+ for f in callbacks:
+ monitor.connect("changed", f)
+
+ [f() for f in callbacks] if initial_call else None
+
+ return monitor
+
+
+def cooldown(
+ cooldown_time: int | float,
+ error: Callable | None = None,
+ return_error: bool = False,
+):
+ """
+ Decorator function that adds a cooldown period to a given function
+
+ :param cooldown_time: the time in seconds to wait before calling the function again
+ :type cooldown_time: int
+ :param error: the function to call if the cooldown period has not been reached yet. Defaults to None
+ :type error: Callable, optional
+ :rtype: decorator
+ """
+
+ def decorator(func):
+ last_call_delay = 0
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ nonlocal last_call_delay
+ current_time = time.time()
+ elapsed_time = current_time - last_call_delay
+ if elapsed_time >= cooldown_time:
+ result = func(*args, **kwargs)
+ last_call_delay = current_time
+ return result
+ else:
+ if return_error is True and error is not None:
+ return error((cooldown_time - elapsed_time), *args, **kwargs)
+ elif error is not None:
+ error((cooldown_time - elapsed_time), *args, **kwargs)
+
+ return wrapper
+
+ return decorator
+
+
+def exec_shell_command(cmd: str) -> str | Literal[False]:
+ """
+ executes a shell command and returns the output
+
+ :param cmd: the shell command to execute
+ :type cmd: str
+ :return: the output of the command or False if an error has occurred
+ :rtype: str | Literal[False]
+ """
+ if not isinstance(cmd, str):
+ raise ValueError # FIXME: add error message
+
+ try:
+ result, output, error, status = GLib.spawn_command_line_sync(cmd) # type: ignore
+ if status != 0:
+ return error.decode()
+ return output.decode()
+ except Exception:
+ pass
+ return False # *unreachable*
+
+
+def exec_shell_command_async(
+ cmd: str | list[str],
+ callback: Callable[[str], Any] | None = None,
+) -> tuple[Gio.Subprocess | None, Gio.DataInputStream]:
+ """
+ executes a shell command and returns the output asynchronously
+
+ :param cmd: the shell command to execute
+ :type cmd: str
+ :param callback: a function to retrieve the result at or `None` to ignore the result
+ :type callback: Callable[[str], Any] | None, optional
+ :return: a Gio.Subprocess object which holds a reference to your process and a Gio.DataInputStream object for stdout
+ :rtype: tuple[Gio.Subprocess | None, Gio.DataInputStream]
+ """
+ process = Gio.Subprocess.new(
+ shlex.split(cmd) if isinstance(cmd, str) else cmd, # type: ignore
+ Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, # type: ignore
+ )
+
+ stdout = Gio.DataInputStream(
+ base_stream=process.get_stdout_pipe(), # type: ignore
+ close_base_stream=True,
+ )
+
+ def reader_loop(stdout: Gio.DataInputStream):
+ def read_line(stream: Gio.DataInputStream, res: Gio.AsyncResult):
+ output, *_ = stream.read_line_finish_utf8(res)
+ if isinstance(output, str):
+ callback(output) if callback else None
+ reader_loop(stream)
+
+ stdout.read_line_async(GLib.PRIORITY_DEFAULT, None, read_line)
+
+ reader_loop(stdout)
+
+ return process, stdout
+
+
+def invoke_repeater(
+ interval: int, func: Callable, *args, initial_call: bool = True
+) -> int:
+ """
+ invokes a function repeatedly with a given interval
+
+ :param interval: the interval in milliseconds to invoke the function
+ :type interval: int
+ :param func: the function to invoke
+ :type func: Callable
+ :param args: list of arguments passed directly to the given function
+ :param initial_call: whether should the given function get called as soon as it is registered. Defaults to False
+ :type initial_call: bool, optional
+ """
+ if initial_call:
+ func(*args)
+ return GLib.timeout_add(interval, func, *args)
+
+
+def get_relative_path(path: str, level: int = 1) -> str:
+ """
+ converts a path to a relative path according to caller's `__file__` variable
+
+ NOTE: This function only works if the caller `__file__` variable is set. means this will work only if you're calling from a python file (not a IDLE / REPL), else it will fallback to the current working directory as `__file__`
+
+ :param path: the path to convert
+ :type path: str
+ :param level: the stack level to get the `__file__` variable from. Defaults to 1
+ :type level: int, optional
+ :return: the relative path
+ :rtype: str
+ """
+ prev_globals = inspect.stack()[level][0].f_globals
+ file_var = (
+ os.path.dirname(os.path.abspath(prev_globals["__file__"]))
+ if "__file__" in prev_globals
+ else os.getcwd()
+ )
+ path = os.path.join(file_var, path)
+ return path
+
+
+def load_dbus_xml(path_to_xml: str):
+ path_to_xml = get_relative_path(path_to_xml, level=2)
+ with open(path_to_xml, "r") as f:
+ file = f.read()
+ return Gio.DBusNodeInfo.new_for_xml(file)
+
+
+def snake_case_to_pascal_case(string: str) -> str:
+ return string.replace("_", " ").title().replace(" ", "")
+
+
+def pascal_case_to_snake_case(string: str) -> str:
+ return "_".join(
+ map(
+ str.lower,
+ re.findall(r"[A-Z]?[a-z]+|[A-Z]{2,}(?=[A-Z][a-z]|\d|\W|$)|\d+", string),
+ )
+ )
+
+
+def snake_case_to_kebab_case(string: str) -> str:
+ return string.strip().lower().replace("_", "-")
+
+
+def kebab_case_to_snake_case(string: str) -> str:
+ return string.replace("-", "_").lower()
+
+
+def get_connectables_for_kwargs(kwargs: dict[str, Callable]) -> Generator:
+ __deprecation_table()
+ for key, value in zip(kwargs.keys(), kwargs.values()):
+ if key.startswith("on_"):
+ yield [snake_case_to_kebab_case(key[3:]), value]
+ elif key.startswith("notify_"):
+ # yield a connectable property
+ yield [f"notify::{snake_case_to_kebab_case(key[7:])}", value]
+
+
+def get_enum_member(
+ enum: type[E], member: str | E, mapping: dict[str, str] = {}, default: Any = MISSING
+) -> E:
+ if isinstance(member, enum):
+ return member
+
+ if not isinstance(member, str):
+ raise ValueError # FIXME: add exception message
+
+ for name, replacement in mapping.items():
+ if member.casefold() == name.casefold():
+ member = replacement
+ break
+
+ try:
+ return getattr(enum, kebab_case_to_snake_case(member).upper())
+ except Exception:
+ if default is MISSING:
+ raise ValueError
+ return default
+
+
+def get_enum_member_name(
+ member: Enum | Any, mapping: dict[str, str] = {}, default: Any = MISSING
+) -> str:
+ if isinstance(member, str):
+ return member
+
+ if isinstance(member, Enum):
+ return member.name
+
+ # GIR type enum...
+ member_name: str | None = None
+ if _name := getattr(member, "first_value_nick", None):
+ member_name = _name
+ elif _name := getattr(member, "value_nick", None):
+ member_name = _name
+
+ if not member_name and default is MISSING:
+ raise ValueError
+ elif not member_name:
+ return default
+
+ member_name = member_name.upper()
+ return mapping.get(member_name, member_name)
+
+
+def bridge_signal(
+ source: GObject.Object,
+ source_signal: str,
+ target: GObject.Object,
+ target_signal: str,
+ notify: bool = False,
+) -> int:
+ def signal_handler(*args, **kwargs):
+ return target.emit(target_signal, *args, **kwargs) # type: ignore
+
+ def notify_handler(*args, **kwargs):
+ return target.notify(target_signal) # type: ignore
+
+ return source.connect( # type: ignore
+ source_signal if not notify else f"notify::{source_signal}",
+ signal_handler if not notify else notify_handler,
+ )
+
+
+def generate_random_string(length: int = 8) -> str:
+ return "".join(random.choices(string.ascii_uppercase + string.digits, k=length))
+
+
+class FunctionAnnotations(NamedTuple, Generic[T]):
+ arguments: dict[str, type]
+ return_type: T | None
+
+
+def get_function_annotations(
+ func: Callable[P, T],
+) -> FunctionAnnotations[T]:
+ args: dict[str, Any] = {}
+ signature = inspect.signature(func)
+ return_type: T | None = (
+ signature.return_annotation
+ if signature.return_annotation is not signature.empty
+ else None
+ )
+ for arg_name, arg_pspec in signature.parameters.items():
+ args[arg_name] = (
+ arg_pspec.annotation if arg_pspec.annotation is not arg_pspec.empty else Any
+ )
+ return FunctionAnnotations(args, return_type)
+
+
+def make_arguments_ignorable(func: Callable[..., T]) -> Callable[..., T]:
+ params = inspect.signature(func).parameters.values()
+ if any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params):
+ return func # no
+
+ args_len = sum(
+ 1
+ for p in params
+ if p.kind
+ in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
+ )
+
+ @wraps(func)
+ def wrapper(*passed_args):
+ return func(*passed_args[:args_len])
+
+ return wrapper
+
+
+def truncate(string: str, max_length: int, suffix: str = "...") -> str:
+ return (
+ string
+ if len(string) <= max_length
+ else string[: max_length - len(suffix)] + suffix
+ )
+
+
+def idle_add(func: Callable, *args, pin: bool = False) -> int:
+ """
+ add a function to be invoked in a lazy manner in the main thread, useful for multi-threaded code
+
+ :param func: the function to be queued
+ :type func: Callable
+ :param args: arguments will be passed to the given function
+ :param pin: whether the function should be invoked as long as it's return value is `True`, when the function returns `False` it won't be called again
+ :type pin: bool, optional
+ """
+ if pin:
+ return GLib.idle_add(func, *args)
+
+ def idle_executor(*largs):
+ func(*largs)
+ return False
+
+ return GLib.idle_add(
+ idle_executor, *args
+ ) # a hack to safely pass a pointer to user's data
+
+
+def remove_handler(handler_id: int):
+ return GLib.source_remove(handler_id)
+
+
+# FIXME: deprecated (please don't use, there's a replacement of each function)
+def set_stylesheet_from_file(file_path: str, compiled: bool = True) -> None:
+ __deprecation_table()
+
+ provider = Gtk.CssProvider()
+ if compiled:
+ with open(file_path, "r") as f:
+ file = f.read()
+ provider.load_from_data(bytearray(compile_css(file), "utf-8")) # type: ignore
+ else:
+ provider.load_from_path(file_path)
+ screen = Gdk.Screen.get_default()
+ context = Gtk.StyleContext()
+ context.add_provider_for_screen(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
+ return
+
+
+def set_stylesheet_from_string(css_string: str, compiled: bool = True) -> None:
+ __deprecation_table()
+
+ provider = Gtk.CssProvider()
+ if compiled:
+ provider.load_from_data(bytearray(compile_css(css_string), "utf-8"))
+ else:
+ provider.load_from_data(bytearray(css_string, "utf-8"))
+ screen = Gdk.Screen.get_default()
+ context = Gtk.StyleContext()
+ context.add_provider_for_screen(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
+ return
+
+
+def idlify(func: Callable, *args) -> int:
+ __deprecation_table()
+ return idle_add(func, *args)
diff --git a/fabric/fabric/widgets/__init__.py b/fabric/fabric/widgets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/fabric/fabric/widgets/box.py b/fabric/fabric/widgets/box.py
new file mode 100644
index 0000000..75bb832
--- /dev/null
+++ b/fabric/fabric/widgets/box.py
@@ -0,0 +1,67 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.container import Container
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Box(Gtk.Box, Container):
+ """a box able to hold widgets as a container"""
+
+ def __init__(
+ self,
+ spacing: int = 0,
+ orientation: Literal[
+ "horizontal",
+ "vertical",
+ "h",
+ "v",
+ ]
+ | Gtk.Orientation = Gtk.Orientation.HORIZONTAL,
+ children: Gtk.Widget | Iterable[Gtk.Widget] | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ) -> None:
+ orientation = get_enum_member(
+ Gtk.Orientation,
+ orientation,
+ {"v": "vertical", "h": "horizontal"},
+ default=Gtk.Orientation.HORIZONTAL,
+ )
+
+ Gtk.Box.__init__(self, spacing=spacing, orientation=orientation) # type: ignore
+ Container.__init__(
+ self,
+ children,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
diff --git a/fabric/fabric/widgets/button.py b/fabric/fabric/widgets/button.py
new file mode 100644
index 0000000..3b4a4fb
--- /dev/null
+++ b/fabric/fabric/widgets/button.py
@@ -0,0 +1,54 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.container import Container
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Button(Gtk.Button, Container):
+ def __init__(
+ self,
+ label: str | None = None,
+ image: Gtk.Image | None = None,
+ child: Gtk.Widget | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Button.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ None,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.set_label(label) if label is not None else None
+ self.set_image(image) if image is not None else None
+ self.add(child) if child is not None else None
diff --git a/fabric/fabric/widgets/centerbox.py b/fabric/fabric/widgets/centerbox.py
new file mode 100644
index 0000000..6397343
--- /dev/null
+++ b/fabric/fabric/widgets/centerbox.py
@@ -0,0 +1,140 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.core.service import Property
+from fabric.widgets.box import Box
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class CenterBox(Box):
+ # using `.fset` directly isn't a good idea but it works for now
+ @Property(list[Gtk.Widget], "read-write")
+ def start_children(self) -> list[Gtk.Widget]:
+ return self.start_container.get_children()
+
+ @start_children.setter
+ def start_children(self, value: Gtk.Widget | Iterable[Gtk.Widget]):
+ return Box.children.fset(self.start_container, value) # type: ignore
+
+ @Property(list[Gtk.Widget], "read-write")
+ def center_children(self) -> list[Gtk.Widget]:
+ return self.center_container.get_children()
+
+ @center_children.setter
+ def center_children(self, value: Gtk.Widget | Iterable[Gtk.Widget]):
+ return Box.children.fset(self.center_container, value) # type: ignore
+
+ @Property(list[Gtk.Widget], "read-write")
+ def end_children(self) -> list[Gtk.Widget]:
+ return self.end_container.get_children()
+
+ @end_children.setter
+ def end_children(self, value: Gtk.Widget | Iterable[Gtk.Widget]):
+ return Box.children.fset(self.end_container, value) # type: ignore
+
+ def __init__(
+ self,
+ start_children: Gtk.Widget | Iterable[Gtk.Widget] | None = None,
+ center_children: Gtk.Widget | Iterable[Gtk.Widget] | None = None,
+ end_children: Gtk.Widget | Iterable[Gtk.Widget] | None = None,
+ spacing: int = 0,
+ orientation: Literal[
+ "horizontal",
+ "vertical",
+ "h",
+ "v",
+ ]
+ | Gtk.Orientation = Gtk.Orientation.HORIZONTAL,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ orientation = get_enum_member(
+ Gtk.Orientation,
+ orientation,
+ {
+ "h": "horizontal",
+ "v": "vertical",
+ },
+ Gtk.Orientation.VERTICAL,
+ )
+ orientation_flipped = {
+ Gtk.Orientation.VERTICAL: Gtk.Orientation.HORIZONTAL,
+ Gtk.Orientation.HORIZONTAL: Gtk.Orientation.VERTICAL,
+ }.get(orientation, Gtk.Orientation.VERTICAL)
+ super().__init__(
+ spacing,
+ orientation_flipped,
+ None,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+
+ def box_factory():
+ return Box(orientation=orientation).build(
+ lambda _, builder: builder.set_vexpand(True)
+ if orientation == Gtk.Orientation.HORIZONTAL
+ else builder.set_hexpand(True)
+ )
+
+ self._inner = box_factory()
+
+ self.start_container = box_factory()
+ self.center_container = box_factory()
+ self.end_container = box_factory()
+
+ self._inner.pack_start(self.start_container, False, False, 0)
+ self._inner.set_center_widget(self.center_container)
+ self._inner.pack_end(self.end_container, False, False, 0)
+
+ self.start_container.children = start_children or ()
+ self.center_container.children = center_children or ()
+ self.end_container.children = end_children or ()
+
+ self.add(self._inner)
+
+ def add_start(self, widget: Gtk.Widget):
+ return self.start_container.add(widget)
+
+ def add_center(self, widget: Gtk.Widget):
+ return self.center_container.add(widget)
+
+ def add_end(self, widget: Gtk.Widget):
+ return self.end_container.add(widget)
+
+ def remove_start(self, widget: Gtk.Widget):
+ return self.start_container.remove(widget)
+
+ def remove_center(self, widget: Gtk.Widget):
+ return self.center_container.remove(widget)
+
+ def remove_end(self, widget: Gtk.Widget):
+ return self.end_container.remove(widget)
diff --git a/fabric/fabric/widgets/checkbutton.py b/fabric/fabric/widgets/checkbutton.py
new file mode 100644
index 0000000..c7ae801
--- /dev/null
+++ b/fabric/fabric/widgets/checkbutton.py
@@ -0,0 +1,51 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.button import Button
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class CheckButton(Gtk.CheckButton, Button):
+ def __init__(
+ self,
+ label: str | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.CheckButton.__init__(self) # type: ignore
+ Button.__init__(
+ self,
+ label,
+ None,
+ None,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
diff --git a/fabric/fabric/widgets/circularprogressbar.py b/fabric/fabric/widgets/circularprogressbar.py
new file mode 100644
index 0000000..9ee2fed
--- /dev/null
+++ b/fabric/fabric/widgets/circularprogressbar.py
@@ -0,0 +1,261 @@
+import gi
+import math
+import cairo
+from typing import Literal, Iterable
+from fabric.core.service import Property
+from fabric.widgets.container import Container
+from fabric.utils.helpers import get_enum_member, clamp
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gdk, Gtk
+
+
+class CircularProgressBar(Gtk.Bin, Container):
+ @Property(float, "read-write", default_value=0.0)
+ def min_value(self) -> float:
+ return self._min_value
+
+ @min_value.setter
+ def min_value(self, value: float):
+ self._min_value = clamp(value, self.min_value, self.max_value)
+ return self.queue_draw()
+
+ @Property(float, "read-write", default_value=1.0)
+ def max_value(self) -> float:
+ return self._max_value
+
+ @max_value.setter
+ def max_value(self, value: float):
+ if value == 0:
+ raise ValueError("max_value cannot be zero")
+ self._max_value = value
+ return self.queue_draw()
+
+ @Property(float, "read-write", default_value=1.0)
+ def value(self) -> float:
+ return self._value
+
+ @value.setter
+ def value(self, value: float):
+ self._value = value
+ return self.queue_draw()
+
+ @Property(bool, "read-write", default_value=False)
+ def pie(self) -> bool:
+ return self._pie
+
+ @pie.setter
+ def pie(self, value: bool):
+ self._pie = value
+ return self.queue_draw()
+
+ @Property(int, "read-write", default_value=4)
+ def line_width(self) -> int:
+ return self._line_width
+
+ @line_width.setter
+ def line_width(self, value: int):
+ self._line_width = value
+ return self.queue_draw()
+
+ @Property(object, "read-write")
+ def line_style(self) -> cairo.LineCap:
+ return self._line_style # type: ignore
+
+ @line_style.setter
+ def line_style(
+ self,
+ line_style: Literal[
+ "none",
+ "butt",
+ "round",
+ "square",
+ ]
+ | cairo.LineCap,
+ ):
+ self._line_style = get_enum_member(cairo.LineCap, line_style) # type: ignore
+ return self.queue_draw()
+
+ @Property(float, "read-write", default_value=0.0)
+ def start_angle(self) -> float:
+ return self._start_angle
+
+ @start_angle.setter
+ def start_angle(self, value: float):
+ self._start_angle = value
+ return self.queue_draw()
+
+ @Property(float, "read-write", default_value=360.0)
+ def end_angle(self) -> float:
+ return self._end_angle
+
+ @end_angle.setter
+ def end_angle(self, value: float):
+ self._end_angle = value
+ return self.queue_draw()
+
+ @Property(bool, "read-write", default_value=False)
+ def invert(self) -> bool:
+ return self._invert
+
+ @invert.setter
+ def invert(self, value: bool):
+ self._invert = value
+ return self.queue_draw()
+
+ def __init__(
+ self,
+ value: float = 1.0,
+ min_value: float = 0.0,
+ max_value: float = 1.0,
+ start_angle: float | None = None,
+ end_angle: float | None = None,
+ line_width: int = 4,
+ line_style: Literal["none", "butt", "round", "square"]
+ | cairo.LineCap = cairo.LineCap.ROUND,
+ pie: bool = False,
+ invert: bool = False, # TODO: implement...
+ child: Gtk.Widget | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.DrawingArea.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ child,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self._value: float = 1.0
+ self._min_value: float = 0.0
+ self._max_value: float = 1.0
+ self._line_width: int = 4
+ self._line_style: cairo.LineCap = cairo.LineCap.ROUND
+ self._pie: bool = False
+ self._invert: bool = False
+ self._start_angle: float = 0.0
+ self._end_angle: float = 360.0
+
+ self.value = value
+ self.min_value = min_value
+ self.max_value = max_value
+ self.line_width = line_width
+ self.line_style = line_style
+ self.pie = pie
+ self.invert = invert
+ self.start_angle = start_angle if start_angle is not None else 0.0
+ self.end_angle = end_angle if end_angle is not None else 360.0
+
+ def do_calculate_radius(self) -> int:
+ alloc = self.get_allocation()
+ return min(alloc.width, alloc.height) // 2
+
+ def do_get_preferred_width(self):
+ return (2 * self.do_calculate_radius(),) * 2
+
+ def do_get_preferred_height(self):
+ return self.do_get_preferred_width()
+
+ def do_draw(self, cr: cairo.Context):
+ state = self.get_state_flags()
+ style_context = self.get_style_context()
+
+ border = style_context.get_border(state) # type: ignore
+ radius_color = style_context.get_color(state) # type: ignore
+ progress_color = style_context.get_border_color(state) # type: ignore
+ background_color = style_context.get_background_color(state) # type: ignore
+
+ line_width = max(
+ self._line_width,
+ border.top, # type: ignore
+ border.bottom, # type: ignore
+ border.left, # type: ignore
+ border.right, # type: ignore
+ style_context.get_property("min-width", state), # type: ignore
+ style_context.get_property("min-height", state), # type: ignore
+ )
+
+ delta = 0
+ center_x = self.get_allocated_width() / 2
+ center_y = self.get_allocated_height() / 2
+ radius = self.do_calculate_radius()
+
+ if (radius - line_width) < 0:
+ line_width = radius
+ else:
+ delta = radius - line_width / 2
+
+ cr.save()
+
+ cr.set_line_cap(self._line_style)
+ cr.set_line_width(line_width)
+
+ # background fill
+ Gdk.cairo_set_source_rgba(cr, background_color) # type: ignore
+ cr.arc(center_x, center_y, delta + (line_width / 2), 0, 2 * math.pi)
+ cr.fill()
+
+ # radius
+ Gdk.cairo_set_source_rgba(cr, radius_color) # type: ignore
+ cr.move_to(center_x, center_y) if self._pie is True else None
+ cr.arc(
+ center_x,
+ center_y,
+ delta + (self._line_width / 2) if self._pie is True else delta,
+ math.radians(self._start_angle),
+ math.radians(self._end_angle),
+ )
+ cr.fill() if self._pie is True else cr.stroke()
+
+ # progress
+ normalized_value = clamp( # normalized & clamped value
+ (self._value - self._min_value) / (self._max_value - self._min_value),
+ 0.0,
+ 1.0,
+ )
+
+ Gdk.cairo_set_source_rgba(cr, progress_color) # type: ignore
+ cr.move_to(center_x, center_y) if self._pie is True else None
+ cr.arc(
+ center_x,
+ center_y,
+ delta + (self._line_width / 2) if self._pie is True else delta,
+ math.radians(self._start_angle),
+ math.radians(
+ self._start_angle
+ + normalized_value * (self._end_angle - self._start_angle)
+ ),
+ )
+ cr.fill() if self._pie is True else cr.stroke()
+
+ # all aboard, draw child (if any)
+ if child := self.get_child():
+ self.propagate_draw(child, cr)
+
+ return cr.restore()
diff --git a/fabric/fabric/widgets/container.py b/fabric/fabric/widgets/container.py
new file mode 100644
index 0000000..9637683
--- /dev/null
+++ b/fabric/fabric/widgets/container.py
@@ -0,0 +1,66 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.core.service import Property
+from fabric.widgets.widget import Widget
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Container(Gtk.Container, Widget):
+ @Property(list[Gtk.Widget], "read-write", install=False)
+ def children(self) -> list[Gtk.Widget]:
+ return self.get_children()
+
+ @children.setter
+ def children(self, value: Gtk.Widget | Iterable[Gtk.Widget]):
+ for old_child in self.get_children():
+ self.remove(old_child)
+ if isinstance(value, (tuple, list)):
+ for widget in value:
+ self.add(widget)
+ return
+ self.add(value)
+ return
+
+ def __init__(
+ self,
+ children: Gtk.Widget | Iterable[Gtk.Widget] | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Container.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ if children:
+ self.children = children
diff --git a/fabric/fabric/widgets/datetime.py b/fabric/fabric/widgets/datetime.py
new file mode 100644
index 0000000..4736b3c
--- /dev/null
+++ b/fabric/fabric/widgets/datetime.py
@@ -0,0 +1,138 @@
+import gi
+import time
+from loguru import logger
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.button import Button
+from fabric.core.service import Property
+from fabric.utils.helpers import invoke_repeater
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, GLib, Gdk
+
+
+class DateTime(Button):
+ @Property(tuple[str, ...], "read-write")
+ def formatters(self):
+ return self._formatters
+
+ @formatters.setter
+ def formatters(self, value: str | Iterable[str]):
+ if isinstance(value, (tuple, list)):
+ self._formatters = tuple(value)
+ elif isinstance(value, str):
+ self._formatters = (value,)
+ if len(self._formatters) < 1:
+ logger.warning(
+ "[DateTime] passed in invalid list of formatters, using default formatters list"
+ )
+ self._formatters = ("%I:%M %p", "%A", "%m-%d-%Y")
+ return
+
+ @Property(int, "read-write")
+ def interval(self):
+ return self._interval
+
+ @interval.setter
+ def interval(self, value: int):
+ self._interval = value
+ if self._repeater_id:
+ GLib.source_remove(self._repeater_id)
+ self._repeater_id = invoke_repeater(self._interval, self.do_update_label)
+ self.do_update_label()
+ return
+
+ def __init__(
+ self,
+ formatters: str | Iterable[str] = ("%I:%M %p", "%A", "%m-%d-%Y"),
+ interval: int = 1000,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ super().__init__(
+ None,
+ None,
+ None,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.add_events(Gdk.EventMask.SCROLL_MASK)
+ self._formatters: tuple[str, ...] = tuple()
+ self._current_index: int = 0
+ self._interval: int = interval
+ self._repeater_id: int | None = None
+
+ self.formatters = formatters
+ self.interval = interval
+
+ self.connect(
+ "button-press-event",
+ lambda *args: self.do_handle_press(*args), # to allow overriding
+ )
+ self.connect("scroll-event", lambda *args: self.do_handle_scroll(*args))
+
+ def do_format(self) -> str:
+ return time.strftime(self._formatters[self._current_index])
+
+ def do_update_label(self):
+ self.set_label(self.do_format())
+ return True
+
+ def do_check_invalid_index(self, index: int) -> bool:
+ return (index < 0) or (index > (len(self.formatters) - 1))
+
+ def do_cycle_next(self):
+ self._current_index = self._current_index + 1
+ if self.do_check_invalid_index(self._current_index):
+ self._current_index = 0 # reset tags
+
+ return self.do_update_label()
+
+ def do_cycle_prev(self):
+ self._current_index = self._current_index - 1
+ if self.do_check_invalid_index(self._current_index):
+ self._current_index = len(self.formatters) - 1
+
+ return self.do_update_label()
+
+ def do_handle_press(self, _, event, *args):
+ match event.button:
+ case 1: # left click
+ self.do_cycle_next()
+ case 3: # right click
+ self.do_cycle_prev()
+ return
+
+ def do_handle_scroll(self, _, event, *args):
+ match event.direction:
+ case Gdk.ScrollDirection.UP: # scrolling up
+ self.do_cycle_next()
+ case Gdk.ScrollDirection.DOWN: # scrolling down
+ self.do_cycle_prev()
+ return
diff --git a/fabric/fabric/widgets/entry.py b/fabric/fabric/widgets/entry.py
new file mode 100644
index 0000000..ca0aa8d
--- /dev/null
+++ b/fabric/fabric/widgets/entry.py
@@ -0,0 +1,57 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.widget import Widget
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Entry(Gtk.Entry, Widget):
+ def __init__(
+ self,
+ text: str | None = None,
+ placeholder: str | None = None,
+ editable: bool = True,
+ password: bool = False,
+ max_length: int = 0,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Entry.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.set_text(text) if text is not None else None
+ self.set_placeholder_text(placeholder) if placeholder is not None else None
+ self.set_max_length(max_length)
+ self.set_editable(editable)
+ self.set_visibility(not password)
diff --git a/fabric/fabric/widgets/eventbox.py b/fabric/fabric/widgets/eventbox.py
new file mode 100644
index 0000000..ccb5d5c
--- /dev/null
+++ b/fabric/fabric/widgets/eventbox.py
@@ -0,0 +1,55 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.widget import EVENT_TYPE
+from fabric.widgets.container import Container
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk
+
+
+class EventBox(Gtk.EventBox, Container):
+ def __init__(
+ self,
+ events: EVENT_TYPE
+ | Gdk.EventMask
+ | Iterable[EVENT_TYPE | Gdk.EventMask]
+ | None = None,
+ child: Gtk.Widget | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.EventBox.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ child,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.add_events(events) if events is not None else None
diff --git a/fabric/fabric/widgets/fixed.py b/fabric/fabric/widgets/fixed.py
new file mode 100644
index 0000000..06df7e0
--- /dev/null
+++ b/fabric/fabric/widgets/fixed.py
@@ -0,0 +1,51 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.widget import Widget
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Fixed(Gtk.Fixed, Widget):
+ def __init__(
+ self,
+ children: Iterable[tuple[Widget, tuple[int, int]]] | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Fixed.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+
+ for child in children or ():
+ self.put(child[0], *child[1])
diff --git a/fabric/fabric/widgets/flowbox.py b/fabric/fabric/widgets/flowbox.py
new file mode 100644
index 0000000..c436938
--- /dev/null
+++ b/fabric/fabric/widgets/flowbox.py
@@ -0,0 +1,66 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.container import Container
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class FlowBox(Gtk.FlowBox, Container):
+ def __init__(
+ self,
+ row_spacing: int = 0,
+ column_spacing: int = 0,
+ orientation: Literal[
+ "horizontal",
+ "vertical",
+ "h",
+ "v",
+ ]
+ | Gtk.Orientation = Gtk.Orientation.HORIZONTAL,
+ children: Gtk.Widget | Iterable[Gtk.Widget] | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.FlowBox.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ children,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.set_row_spacing(row_spacing)
+ self.set_column_spacing(column_spacing)
+ self.set_orientation(
+ get_enum_member(
+ Gtk.Orientation, orientation, default=Gtk.Orientation.HORIZONTAL
+ )
+ )
diff --git a/fabric/fabric/widgets/grid.py b/fabric/fabric/widgets/grid.py
new file mode 100644
index 0000000..306b78a
--- /dev/null
+++ b/fabric/fabric/widgets/grid.py
@@ -0,0 +1,71 @@
+import gi
+from collections.abc import Iterable
+from typing import Literal
+
+from fabric.widgets.widget import Widget
+from gi.repository import Gtk
+
+
+class Grid(Gtk.Grid, Widget):
+ def __init__(
+ self,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ row_spacing: int = 0,
+ column_spacing: int = 0,
+ column_homogeneous: bool = False,
+ row_homogeneous: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Grid.__init__(self)
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.set_row_spacing(row_spacing)
+ self.set_column_spacing(column_spacing)
+ self.set_column_homogeneous(column_homogeneous)
+ self.set_row_homogeneous(row_homogeneous)
+
+ def attach_flow(
+ self, children: Iterable[Widget], columns, start_row=0, start_col=0
+ ):
+ """
+ Adds widgets to a Gtk.Grid in a flow layout.
+
+ Args:
+ children (list): List of Gtk.Widget to add.
+ columns (int): Number of columns in the grid.
+ start_row (int): Optional starting row.
+ start_col (int): Optional starting column.
+ """
+ for index, child in enumerate(children):
+ row = start_row + index // columns
+ col = start_col + index % columns
+ self.attach(child, col, row, 1, 1)
diff --git a/fabric/fabric/widgets/image.py b/fabric/fabric/widgets/image.py
new file mode 100644
index 0000000..78733f3
--- /dev/null
+++ b/fabric/fabric/widgets/image.py
@@ -0,0 +1,149 @@
+import gi
+from typing import Literal, overload
+from collections.abc import Iterable
+from fabric.widgets.widget import Widget
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, GdkPixbuf
+
+
+class Image(Gtk.Image, Widget):
+ @overload
+ def __init__(
+ self,
+ image_file: str | None = None,
+ icon_name: None = None,
+ icon_size: int = 24,
+ pixbuf: None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ image_file: None = None,
+ icon_name: str | None = None,
+ icon_size: int = 24,
+ pixbuf: None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+ @overload
+ def __init__(
+ self,
+ image_file: None = None,
+ icon_name: None = None,
+ icon_size: int = 24,
+ pixbuf: GdkPixbuf.Pixbuf | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+
+ def __init__(
+ self,
+ image_file: str | None = None,
+ icon_name: str | None = None,
+ icon_size: int = 24,
+ pixbuf: GdkPixbuf.Pixbuf | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Image.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+
+ if image_file is not None:
+ alloc = self.get_size_request()
+ pixsize: tuple[int, int] = (alloc.width, alloc.height) # type: ignore
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(image_file, *pixsize)
+ elif icon_name is not None:
+ self.set_from_icon_name(icon_name, icon_size)
+
+ self.set_from_pixbuf(pixbuf) if pixbuf is not None else None
+
+ # overrides
+ def set_from_icon_name(
+ self,
+ icon_name: str,
+ icon_size: int = 24,
+ ):
+ super().set_from_icon_name(
+ icon_name,
+ Gtk.IconSize.LARGE_TOOLBAR, # type: ignore
+ )
+ return self.set_pixel_size(icon_size)
diff --git a/fabric/fabric/widgets/label.py b/fabric/fabric/widgets/label.py
new file mode 100644
index 0000000..9df8891
--- /dev/null
+++ b/fabric/fabric/widgets/label.py
@@ -0,0 +1,174 @@
+import gi
+from typing import Literal, overload
+from collections.abc import Iterable
+from fabric.widgets.widget import Widget
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Pango
+
+
+class Label(Gtk.Label, Widget):
+ @overload
+ def __init__(
+ self,
+ label: str | None = None,
+ markup: None = None,
+ justification: Literal[
+ "left",
+ "right",
+ "center",
+ "fill",
+ ]
+ | Gtk.Justification = Gtk.Justification.LEFT,
+ ellipsization: Literal[
+ "none",
+ "start",
+ "middle",
+ "end",
+ ]
+ | Pango.EllipsizeMode = Pango.EllipsizeMode.NONE,
+ chars_width: int = -1,
+ max_chars_width: int = -1,
+ line_wrap: Literal["word", "char", "word-char"] | Pango.WrapMode | None = None,
+ angle: float = 0.0,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ label: None = None,
+ markup: str | None = None,
+ justification: Literal[
+ "left",
+ "right",
+ "center",
+ "fill",
+ ]
+ | Gtk.Justification = Gtk.Justification.LEFT,
+ ellipsization: Literal[
+ "none",
+ "start",
+ "middle",
+ "end",
+ ]
+ | Pango.EllipsizeMode = Pango.EllipsizeMode.NONE,
+ chars_width: int = -1,
+ max_chars_width: int = -1,
+ line_wrap: Literal["word", "char", "word-char"] | Pango.WrapMode | None = None,
+ angle: float = 0.0,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+
+ def __init__(
+ self,
+ label: str | None = None,
+ markup: str | None = None,
+ justification: Literal[
+ "left",
+ "right",
+ "center",
+ "fill",
+ ]
+ | Gtk.Justification = Gtk.Justification.LEFT,
+ ellipsization: Literal[
+ "none",
+ "start",
+ "middle",
+ "end",
+ ]
+ | Pango.EllipsizeMode = Pango.EllipsizeMode.NONE,
+ chars_width: int = -1,
+ max_chars_width: int = -1,
+ line_wrap: Literal["word", "char", "word-char"] | Pango.WrapMode | None = None,
+ angle: float = 0.0,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Label.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.set_label(label) if label is not None else None
+ self.set_markup(markup) if markup is not None else None
+
+ self.set_justify(
+ get_enum_member(
+ Gtk.Justification, justification, default=Gtk.Justification.LEFT
+ )
+ )
+ self.set_ellipsize(
+ get_enum_member(
+ Pango.EllipsizeMode, ellipsization, default=Pango.EllipsizeMode.NONE
+ )
+ )
+
+ self.set_width_chars(chars_width)
+ self.set_max_width_chars(max_chars_width)
+ if line_wrap is not None:
+ self.set_line_wrap(True)
+ self.set_line_wrap_mode(
+ get_enum_member(Pango.WrapMode, line_wrap, default=Pango.WrapMode.WORD)
+ )
+ self.set_angle(angle)
diff --git a/fabric/fabric/widgets/overlay.py b/fabric/fabric/widgets/overlay.py
new file mode 100644
index 0000000..a72b7ae
--- /dev/null
+++ b/fabric/fabric/widgets/overlay.py
@@ -0,0 +1,82 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.core.service import Property
+from fabric.widgets.container import Container
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Overlay(Gtk.Overlay, Container):
+ @Property(list[Gtk.Widget], "read-write")
+ def overlays(self) -> list[Gtk.Widget]:
+ return self._overlays
+
+ @overlays.setter
+ def overlays(self, value: Gtk.Widget | Iterable[Gtk.Widget]):
+ for old_overlay in self._overlays:
+ self.remove(old_overlay)
+ if isinstance(value, (tuple, list)):
+ for widget in value:
+ self.add_overlay(widget)
+ return
+ self.add_overlay(value) # type: ignore
+ return
+
+ def __init__(
+ self,
+ child: Gtk.Widget | None = None,
+ overlays: Gtk.Widget | list[Gtk.Widget] | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Overlay.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ child,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self._overlays = []
+ if overlays is not None:
+ self.overlays = overlays
+
+ # overrides
+ def remove_overlay(self, overlay: Gtk.Widget):
+ if overlay not in self._overlays:
+ raise ValueError(f"widget {overlay} does not exist in {self} as a overlay")
+ self.remove(overlay)
+ self._overlays.remove(overlay)
+ return
+
+ def add_overlay(self, overlay: Gtk.Widget):
+ super().add_overlay(overlay)
+ self._overlays.append(overlay)
+ return
diff --git a/fabric/fabric/widgets/revealer.py b/fabric/fabric/widgets/revealer.py
new file mode 100644
index 0000000..8f0f06e
--- /dev/null
+++ b/fabric/fabric/widgets/revealer.py
@@ -0,0 +1,119 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.core.service import Property
+from fabric.widgets.container import Container
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Revealer(Gtk.Revealer, Container):
+ @Property(bool, "read-write", default_value=False, install=False)
+ def child_revealed(self):
+ return self.get_reveal_child()
+
+ @child_revealed.setter
+ def child_revealed(self, value: bool):
+ return self.set_reveal_child(value)
+
+ @Property(bool, "readable", default_value=False)
+ def fully_revealed(self):
+ return self.get_child_revealed()
+
+ @Property(
+ Gtk.RevealerTransitionType,
+ "read-write",
+ default_value=Gtk.RevealerTransitionType.NONE,
+ install=False,
+ )
+ def transition_type(self) -> Gtk.RevealerTransitionType:
+ return self.get_transition_type()
+
+ @transition_type.setter
+ def transition_type(
+ self,
+ value: Literal[
+ "none", "crossfade", "slide-right", "slide-left", "slide-up", "slide-down"
+ ]
+ | Gtk.RevealerTransitionType,
+ ):
+ return self.set_transition_type(
+ get_enum_member(Gtk.RevealerTransitionType, value)
+ )
+
+ @Property(int, "read-write", install=False)
+ def transition_duration(self):
+ return self.get_transition_duration()
+
+ @transition_duration.setter
+ def transition_duration(self, value: int):
+ return self.set_transition_duration(value)
+
+ def __init__(
+ self,
+ child: Gtk.Widget | None = None,
+ child_revealed: bool = False,
+ transition_type: Literal[
+ "none",
+ "crossfade",
+ "slide-right",
+ "slide-left",
+ "slide-up",
+ "slide-down",
+ # "swing-right",
+ # "swing-left",
+ # "swing-up",
+ # "swing-down",
+ ]
+ | Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.NONE,
+ transition_duration: int = 400,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Revealer.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ child,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.child_revealed = child_revealed
+ self.transition_type = transition_type
+ self.transition_duration = transition_duration
+
+ def reveal(self):
+ self.child_visible = True
+ self.child_revealed = True
+ return
+
+ def unreveal(self):
+ self.child_revealed = False
+ return
diff --git a/fabric/fabric/widgets/scale.py b/fabric/fabric/widgets/scale.py
new file mode 100644
index 0000000..be47345
--- /dev/null
+++ b/fabric/fabric/widgets/scale.py
@@ -0,0 +1,142 @@
+import gi
+from collections.abc import Iterable
+from typing import Literal, NamedTuple
+from fabric.widgets.widget import Widget
+from fabric.core.service import Property
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class ScaleMark(NamedTuple):
+ value: float
+ markup: str | None = None
+ position: (
+ Literal[
+ "bottom",
+ "left",
+ "right",
+ "top",
+ ]
+ | Gtk.PositionType
+ ) = Gtk.PositionType.RIGHT
+
+
+class Scale(Gtk.Scale, Widget):
+ @Property(float, "read-write", install=False)
+ def value(self) -> float:
+ return self.get_value()
+
+ @value.setter
+ def value(self, value: float):
+ return self.set_value(value)
+
+ @Property(float, "read-write", default_value=0.0)
+ def min_value(self) -> float:
+ return self._min_value
+
+ @min_value.setter
+ def min_value(self, value: float):
+ self._min_value = value
+ return self.set_range(self._min_value, self._max_value)
+
+ @Property(float, "read-write", default_value=1.0)
+ def max_value(self) -> float:
+ return self._max_value
+
+ @max_value.setter
+ def max_value(self, value: float):
+ self._max_value = value
+ return self.set_range(self._min_value, self._max_value)
+
+ def __init__(
+ self,
+ value: float = 0.0,
+ min_value: float = 0.0,
+ max_value: float = 1.0,
+ draw_value: bool = False,
+ value_position: Literal["left", "right", "top", "bottom"]
+ | Gtk.PositionType = Gtk.PositionType.TOP,
+ orientation: Literal[
+ "horizontal",
+ "vertical",
+ "h",
+ "v",
+ ]
+ | Gtk.Orientation = Gtk.Orientation.HORIZONTAL,
+ increments: tuple[float, float] | None = None,
+ marks: Iterable[ScaleMark] | None = None,
+ digits: int = 1,
+ inverted: bool = False,
+ has_origin: bool = True,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Scale.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+
+ self._min_value: float = 0
+ self._max_value: float = 1.0
+
+ self.max_value = max_value
+ self.min_value = min_value
+ self.value = value
+
+ self.set_orientation(
+ get_enum_member(
+ Gtk.Orientation,
+ orientation,
+ {"v": "vertical", "h": "horizontal"},
+ default=Gtk.Orientation.HORIZONTAL,
+ )
+ )
+ self.set_draw_value(draw_value)
+ self.set_value_pos(
+ get_enum_member(
+ Gtk.PositionType, value_position, default=Gtk.PositionType.TOP
+ )
+ )
+ if increments is not None:
+ self.set_increments(increments[0], increments[1])
+ for mark in marks or ():
+ self.add_mark(
+ mark[0],
+ get_enum_member(
+ Gtk.PositionType, mark[2], default=Gtk.PositionType.TOP
+ ),
+ mark[1],
+ )
+ self.set_digits(digits)
+ self.set_inverted(inverted)
+ self.set_has_origin(has_origin)
diff --git a/fabric/fabric/widgets/scrolledwindow.py b/fabric/fabric/widgets/scrolledwindow.py
new file mode 100644
index 0000000..0b528de
--- /dev/null
+++ b/fabric/fabric/widgets/scrolledwindow.py
@@ -0,0 +1,114 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.container import Container
+from fabric.core.service import Property
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class ScrolledWindow(Gtk.ScrolledWindow, Container):
+ @Property(tuple[int, int], "read-write")
+ def min_content_size(self):
+ return self.get_min_content_width(), self.get_min_content_height()
+
+ @min_content_size.setter
+ def min_content_size(self, value: Iterable[int]):
+ if isinstance(value, (tuple, list)) and len(value) == 2:
+ self.set_min_content_width(value[0])
+ self.set_min_content_height(value[1])
+ return
+ raise ValueError(
+ "the size must be tuple or a list with two values, (width, height)"
+ )
+
+ @Property(tuple[int, int], "read-write")
+ def max_content_size(self):
+ return self.get_max_content_width(), self.get_max_content_height()
+
+ @max_content_size.setter
+ def max_content_size(self, value: Iterable[int]):
+ if isinstance(value, (tuple, list)) and len(value) == 2:
+ self.set_max_content_width(value[0])
+ self.set_max_content_height(value[1])
+ return
+ raise ValueError(
+ "the size must be tuple or a list with two values, (width, height)"
+ )
+
+ def __init__(
+ self,
+ min_content_size: Iterable[int] = (-1, -1),
+ max_content_size: Iterable[int] = (-1, -1),
+ propagate_width: bool = True,
+ propagate_height: bool = True,
+ kinetic_scroll: bool = False,
+ overlay_scroll: bool = False,
+ h_scrollbar_policy: Literal[
+ "always",
+ "automatic",
+ "never",
+ "external",
+ ]
+ | Gtk.PolicyType = Gtk.PolicyType.AUTOMATIC,
+ v_scrollbar_policy: Literal[
+ "always",
+ "automatic",
+ "never",
+ "external",
+ ]
+ | Gtk.PolicyType = Gtk.PolicyType.AUTOMATIC,
+ child: Gtk.Widget | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.ScrolledWindow.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ child,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+
+ self.min_content_size = min_content_size
+ self.max_content_size = max_content_size
+ self.set_propagate_natural_width(propagate_width)
+ self.set_propagate_natural_height(propagate_height)
+ self.set_kinetic_scrolling(kinetic_scroll)
+ self.set_overlay_scrolling(overlay_scroll)
+ self.set_policy(
+ get_enum_member(
+ Gtk.PolicyType, h_scrollbar_policy, default=Gtk.PolicyType.AUTOMATIC
+ ),
+ get_enum_member(
+ Gtk.PolicyType, v_scrollbar_policy, default=Gtk.PolicyType.AUTOMATIC
+ ),
+ )
diff --git a/fabric/fabric/widgets/separator.py b/fabric/fabric/widgets/separator.py
new file mode 100644
index 0000000..14c80cc
--- /dev/null
+++ b/fabric/fabric/widgets/separator.py
@@ -0,0 +1,60 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.widget import Widget
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Separator(Gtk.Separator, Widget):
+ def __init__(
+ self,
+ orientation: Literal[
+ "horizontal",
+ "vertical",
+ "h",
+ "v",
+ ]
+ | Gtk.Orientation = Gtk.Orientation.HORIZONTAL,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Separator.__init__(self)
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self.set_orientation(
+ get_enum_member(
+ Gtk.Orientation, orientation, default=Gtk.Orientation.HORIZONTAL
+ )
+ )
diff --git a/fabric/fabric/widgets/shapes/__init__.py b/fabric/fabric/widgets/shapes/__init__.py
new file mode 100644
index 0000000..ec3464c
--- /dev/null
+++ b/fabric/fabric/widgets/shapes/__init__.py
@@ -0,0 +1,4 @@
+from .star import Star
+from .corner import Corner, CornerOrientation
+
+__all__ = ["Star", "Corner", "CornerOrientation"]
diff --git a/fabric/fabric/widgets/shapes/corner.py b/fabric/fabric/widgets/shapes/corner.py
new file mode 100644
index 0000000..4b80712
--- /dev/null
+++ b/fabric/fabric/widgets/shapes/corner.py
@@ -0,0 +1,142 @@
+import gi
+import cairo
+from enum import Enum
+from typing import Literal
+from collections.abc import Iterable
+from fabric.core.service import Property
+from fabric.widgets.widget import Widget
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk
+
+
+class CornerOrientation(Enum):
+ TOP_LEFT = 1
+ TOP_RIGHT = 2
+ BOTTOM_LEFT = 3
+ BOTTOM_RIGHT = 4
+
+
+class Corner(Gtk.DrawingArea, Widget):
+ @staticmethod
+ def render_shape(
+ cr: cairo.Context,
+ width: float,
+ height: float,
+ orientation: CornerOrientation = CornerOrientation.TOP_LEFT,
+ ):
+ # _this is fine_
+ cr.save()
+ match orientation:
+ case CornerOrientation.TOP_LEFT:
+ cr.move_to(0, height)
+ cr.line_to(0, 0)
+ cr.line_to(width, 0)
+ cr.curve_to(0, 0, 0, height, 0, height)
+ case CornerOrientation.TOP_RIGHT:
+ cr.move_to(width, height)
+ cr.line_to(width, 0)
+ cr.line_to(0, 0)
+ cr.curve_to(width, 0, width, height, width, height)
+ case CornerOrientation.BOTTOM_LEFT:
+ cr.move_to(0, 0)
+ cr.line_to(0, height)
+ cr.line_to(width, height)
+ cr.curve_to(0, height, 0, 0, 0, 0)
+ case CornerOrientation.BOTTOM_RIGHT:
+ cr.move_to(width, 0)
+ cr.line_to(width, height)
+ cr.line_to(0, height)
+ cr.curve_to(width, height, width, 0, width, 0)
+ cr.close_path()
+ cr.restore()
+ return
+
+ @Property(CornerOrientation, "read-write")
+ def orientation(self) -> CornerOrientation:
+ return self._orientation
+
+ @orientation.setter
+ def orientation(
+ self,
+ value: Literal["top-left", "top-right", "bottom-left", "bottom-right"]
+ | CornerOrientation,
+ ):
+ self._orientation = get_enum_member(CornerOrientation, value)
+ return self.queue_draw()
+
+ def __init__(
+ self,
+ orientation: Literal["top-left", "top-right", "bottom-left", "bottom-right"]
+ | CornerOrientation = CornerOrientation.TOP_RIGHT,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.DrawingArea.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self._orientation = get_enum_member(CornerOrientation, orientation)
+ self.connect("draw", self.on_draw)
+
+ def on_draw(self, _, cr: cairo.Context):
+ aloc: cairo.Rectangle = self.get_allocation() # type: ignore
+ # ^ hear me out, Gtk.Allocation == Gdk.Rectangle == cairo.Rectangle
+ width, height = aloc.width, aloc.height
+
+ state = self.get_state_flags()
+ context: Gtk.StyleContext = self.get_style_context()
+ border_color: Gdk.RGBA = context.get_border_color(state) # type: ignore
+ border_width = (
+ max(
+ (border := context.get_border(state)).top, # type: ignore
+ border.bottom, # type: ignore
+ border.left, # type: ignore
+ border.right, # type: ignore
+ )
+ * 2 # the power of two because half the size of the border is out the bounding box
+ )
+
+ cr.save()
+
+ self.render_shape(cr, width, height, self._orientation)
+ cr.clip()
+ Gtk.render_background(context, cr, 0, 0, width, height)
+
+ if border_width:
+ Gdk.cairo_set_source_rgba(cr, border_color) # type: ignore
+ cr.set_line_width(border_width)
+ self.render_shape(cr, width, height, self._orientation)
+ cr.stroke()
+
+ cr.restore()
+ return
diff --git a/fabric/fabric/widgets/shapes/star.py b/fabric/fabric/widgets/shapes/star.py
new file mode 100644
index 0000000..8ea2342
--- /dev/null
+++ b/fabric/fabric/widgets/shapes/star.py
@@ -0,0 +1,133 @@
+import gi
+import math
+import cairo
+from typing import Literal
+from collections.abc import Iterable
+from fabric.core.service import Property
+from fabric.widgets.widget import Widget
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk
+
+
+class Star(Gtk.DrawingArea, Widget):
+ @staticmethod
+ def render_shape(
+ cr: cairo.Context,
+ width: float,
+ height: float,
+ points: int = 5,
+ ratio: float = 0.5,
+ ):
+ a = 2 * math.pi / points
+ s = min(width, height) / 2
+ rs = s * ratio
+
+ cr.save()
+
+ cr.translate(width / 2, height / 2)
+ cr.rotate(-math.pi / 2)
+
+ for i in range(points):
+ cr.line_to(s * math.cos(i * a), s * math.sin(i * a))
+ cr.line_to(rs * math.cos((i + 0.5) * a), rs * math.sin((i + 0.5) * a))
+
+ cr.close_path()
+ cr.restore()
+ return
+
+ @Property(int, "read-write")
+ def points(self) -> int:
+ return self._points
+
+ @points.setter
+ def points(self, value: int):
+ self._points = value
+ return self.queue_draw()
+
+ @Property(float, "read-write")
+ def ratio(self) -> float:
+ return self._ratio
+
+ @ratio.setter
+ def ratio(self, value: float):
+ self._ratio = value
+ return self.queue_draw()
+
+ def __init__(
+ self,
+ points: int = 5,
+ ratio: float = 0.5,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.DrawingArea.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self._points = points
+ self._ratio = ratio
+
+ self.connect("draw", self.on_draw)
+
+ def on_draw(self, _, cr: cairo.Context):
+ aloc: cairo.Rectangle = self.get_allocation() # type: ignore
+ width, height = aloc.width, aloc.height
+
+ state = self.get_state_flags()
+ context: Gtk.StyleContext = self.get_style_context()
+ border_color: Gdk.RGBA = context.get_border_color(state) # type: ignore
+ border_width = (
+ max(
+ (border := context.get_border(state)).top, # type: ignore
+ border.bottom, # type: ignore
+ border.left, # type: ignore
+ border.right, # type: ignore
+ )
+ * 2
+ )
+
+ cr.save()
+
+ # render the background
+ self.render_shape(cr, width, height, self._points, self._ratio)
+ cr.clip()
+ Gtk.render_background(context, cr, 0, 0, width, height)
+
+ if border_width:
+ # put the border
+ Gdk.cairo_set_source_rgba(cr, border_color) # type: ignore
+ cr.set_line_width(border_width)
+ self.render_shape(cr, width, height, self._points, self._ratio)
+ cr.stroke()
+
+ cr.restore()
+ return
diff --git a/fabric/fabric/widgets/stack.py b/fabric/fabric/widgets/stack.py
new file mode 100644
index 0000000..8c4b22d
--- /dev/null
+++ b/fabric/fabric/widgets/stack.py
@@ -0,0 +1,134 @@
+import gi
+from typing import Literal
+from collections.abc import Iterable
+from fabric.widgets.container import Container
+from fabric.core.service import Property
+from fabric.utils.helpers import get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk
+
+
+class Stack(Gtk.Stack, Container):
+ @Property(
+ Gtk.StackTransitionType,
+ "read-write",
+ default_value=Gtk.StackTransitionType.NONE,
+ install=False,
+ )
+ def transition_type(self) -> Gtk.StackTransitionType:
+ return self.get_transition_type()
+
+ @transition_type.setter
+ def transition_type(
+ self,
+ value: Literal[
+ "none",
+ "crossfade",
+ "slide-right",
+ "slide-left",
+ "slide-up",
+ "slide-down",
+ "slide-left-right",
+ "slide-up-down",
+ "over-up",
+ "over-down",
+ "over-left",
+ "over-right",
+ "under-up",
+ "under-down",
+ "under-left",
+ "under-right",
+ "over-up-down",
+ "over-down-up",
+ "over-left-right",
+ "over-right-left",
+ ]
+ | Gtk.StackTransitionType,
+ ):
+ return self.set_transition_type(get_enum_member(Gtk.StackTransitionType, value))
+
+ @Property(int, "read-write", install=False)
+ def transition_duration(self):
+ return self.get_transition_duration()
+
+ @transition_duration.setter
+ def transition_duration(self, value: int):
+ return self.set_transition_duration(value)
+
+ @Property(bool, "read-write", default_value=False, install=False)
+ def interpolate_size(self):
+ return self.get_interpolate_size()
+
+ @interpolate_size.setter
+ def interpolate_size(self, value: bool):
+ return self.set_interpolate_size(value)
+
+ def __init__(
+ self,
+ transition_type: Literal[
+ "none",
+ "crossfade",
+ "slide-right",
+ "slide-left",
+ "slide-up",
+ "slide-down",
+ "slide-left-right",
+ "slide-up-down",
+ "over-up",
+ "over-down",
+ "over-left",
+ "over-right",
+ "under-up",
+ "under-down",
+ "under-left",
+ "under-right",
+ "over-up-down",
+ "over-down-up",
+ "over-left-right",
+ "over-right-left",
+ ]
+ | Gtk.StackTransitionType = Gtk.StackTransitionType.NONE,
+ transition_duration: int = 400,
+ interpolate_size: bool = False,
+ children: Gtk.Widget | Iterable[Gtk.Widget] | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Stack.__init__(self) # type: ignore
+ Container.__init__(
+ self,
+ children,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+
+ self.transition_type = transition_type
+ self.transition_duration = transition_duration
+ self.interpolate_size = interpolate_size
diff --git a/fabric/fabric/widgets/svg.py b/fabric/fabric/widgets/svg.py
new file mode 100644
index 0000000..710ef99
--- /dev/null
+++ b/fabric/fabric/widgets/svg.py
@@ -0,0 +1,191 @@
+import gi
+import cairo
+from loguru import logger
+from typing import Literal, overload
+from collections.abc import Iterable
+from fabric.widgets.widget import Widget
+from fabric.utils import compile_css
+
+gi.require_version("Gtk", "3.0")
+gi.require_version("Rsvg", "2.0")
+from gi.repository import Rsvg, Gtk
+
+
+class Svg(Gtk.DrawingArea, Widget):
+ @overload
+ def __init__(
+ self,
+ svg_file: str | None = None,
+ svg_string: None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+
+ @overload
+ def __init__(
+ self,
+ svg_file: None = None,
+ svg_string: str | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+
+ def __init__(
+ self,
+ svg_file: str | None = None,
+ svg_string: str | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.DrawingArea.__init__(self) # type: ignore
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ None,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ if svg_string and svg_file:
+ raise ValueError(
+ "both svg_string and svg_file can't be set at the same time"
+ )
+
+ if not svg_string and not svg_file:
+ raise ValueError("you must provide a source to load the svg from")
+
+ if svg_file:
+ self._handle = Rsvg.Handle.new_from_file(svg_file)
+ else:
+ self._handle = Rsvg.Handle.new_from_data(svg_string.encode()) # type: ignore
+
+ self._style_compiled: str | None = None
+ if style:
+ self.set_style(style)
+
+ def do_draw(self, cr: cairo.Context):
+ if not self._handle:
+ return
+
+ alloc = self.get_allocation()
+ width: int = alloc.width # type: ignore
+ height: int = alloc.height # type: ignore
+
+ rect = Rsvg.Rectangle()
+ rect.x = rect.y = 0 # type: ignore
+ rect.width = width # type: ignore
+ rect.height = height # type: ignore
+
+ if self._style_compiled is not None and self._handle.set_stylesheet(
+ self._style_compiled.encode() # type: ignore
+ ):
+ logger.error("[Svg] failed to apply style, probably invalid style property")
+ self._handle.set_dpi((self.get_scale_factor() * 160))
+
+ cr.save()
+ cr.set_antialias(cairo.Antialias.BEST)
+ self._handle.render_document(cr, rect) # type: ignore
+ cr.restore()
+
+ return
+
+ def do_finalize_handle(self):
+ if not self._handle:
+ return
+ self._handle.free()
+ del self._handle
+
+ self._handle = None
+ return
+
+ def get_svg_size(self) -> tuple[int, int] | None:
+ """Get the dimensions of the loaded svg buffer (if any)
+
+ :return: a tuple holding (width, height) values, None if couldn't find a loaded svg
+ :rtype: tuple[int, int]
+ """
+ if not self._handle:
+ return None
+ return self._handle.props.width, self._handle.props.height # type: ignore
+
+ def set_from_file(self, file: str):
+ self.do_finalize_handle()
+ self._handle = Rsvg.Handle.new_from_file(file)
+ return self.queue_draw()
+
+ def set_from_string(self, string: str):
+ self.do_finalize_handle()
+ self._handle = Rsvg.Handle.new_from_data(string.encode()) # type: ignore
+ return self.queue_draw()
+
+ # override
+ def set_style(
+ self,
+ style: str,
+ compiled: bool = True,
+ append: bool = False, # TODO: implement
+ add_brackets: bool = True,
+ ) -> None:
+ if compiled:
+ self._style_compiled = compile_css(
+ f"* {{ {style} }}"
+ if "{" not in style or "}" not in style and add_brackets is True
+ else style
+ )
+ else:
+ self._style_compiled = style
+
+ return self.queue_draw()
diff --git a/fabric/fabric/widgets/wayland.py b/fabric/fabric/widgets/wayland.py
new file mode 100644
index 0000000..0d3c686
--- /dev/null
+++ b/fabric/fabric/widgets/wayland.py
@@ -0,0 +1,306 @@
+import gi
+import re
+from enum import Enum
+from typing import cast, Literal
+from collections.abc import Iterable
+from fabric.core.service import Property
+from fabric.widgets.window import Window
+from fabric.utils.helpers import extract_css_values, get_enum_member
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk
+
+try:
+ gi.require_version("GtkLayerShell", "0.1")
+ from gi.repository import GtkLayerShell
+except:
+ raise ImportError(
+ "looks like we don't have gtk-layer-shell installed, make sure to install it first (as well as using wayland)"
+ )
+
+
+class WaylandWindowExclusivity(Enum):
+ NONE = 1
+ NORMAL = 2
+ AUTO = 3
+
+
+class WaylandWindow(Window):
+ @Property(
+ GtkLayerShell.Layer,
+ flags="read-write",
+ default_value=GtkLayerShell.Layer.TOP,
+ )
+ def layer(self) -> GtkLayerShell.Layer: # type: ignore
+ return self._layer # type: ignore
+
+ @layer.setter
+ def layer(
+ self,
+ value: Literal["background", "bottom", "top", "overlay"] | GtkLayerShell.Layer,
+ ) -> None:
+ self._layer = get_enum_member(
+ GtkLayerShell.Layer, value, default=GtkLayerShell.Layer.TOP
+ )
+ return GtkLayerShell.set_layer(self, self._layer)
+
+ @Property(int, "read-write")
+ def monitor(self) -> int:
+ if not (monitor := cast(Gdk.Monitor, GtkLayerShell.get_monitor(self))):
+ return -1
+ display = monitor.get_display() or Gdk.Display.get_default()
+ for i in range(0, display.get_n_monitors()):
+ if display.get_monitor(i) is monitor:
+ return i
+ return -1
+
+ @monitor.setter
+ def monitor(self, monitor: int | Gdk.Monitor) -> bool:
+ if isinstance(monitor, int):
+ display = Gdk.Display().get_default()
+ monitor = display.get_monitor(monitor)
+ return (
+ (GtkLayerShell.set_monitor(self, monitor), True)[1]
+ if monitor is not None
+ else False
+ )
+
+ @Property(WaylandWindowExclusivity, "read-write")
+ def exclusivity(self) -> WaylandWindowExclusivity:
+ return self._exclusivity
+
+ @exclusivity.setter
+ def exclusivity(
+ self, value: Literal["none", "normal", "auto"] | WaylandWindowExclusivity
+ ) -> None:
+ value = get_enum_member(
+ WaylandWindowExclusivity, value, default=WaylandWindowExclusivity.NONE
+ )
+ self._exclusivity = value
+ match value:
+ case WaylandWindowExclusivity.NORMAL:
+ return GtkLayerShell.set_exclusive_zone(self, True)
+ case WaylandWindowExclusivity.AUTO:
+ return GtkLayerShell.auto_exclusive_zone_enable(self)
+ case _:
+ return GtkLayerShell.set_exclusive_zone(self, False)
+
+ @Property(
+ GtkLayerShell.KeyboardMode,
+ "read-write",
+ default_value=GtkLayerShell.KeyboardMode.NONE,
+ )
+ def keyboard_mode(self) -> GtkLayerShell.KeyboardMode:
+ return self._keyboard_mode
+
+ @keyboard_mode.setter
+ def keyboard_mode(
+ self,
+ value: Literal[
+ "none",
+ "exclusive",
+ "on-demand",
+ "entry-number",
+ ]
+ | GtkLayerShell.KeyboardMode,
+ ):
+ self._keyboard_mode = get_enum_member(
+ GtkLayerShell.KeyboardMode, value, default=GtkLayerShell.KeyboardMode.NONE
+ )
+ return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode)
+
+ @Property(tuple[GtkLayerShell.Edge, ...], "read-write")
+ def anchor(self):
+ return tuple(
+ x
+ for x in [
+ GtkLayerShell.Edge.TOP,
+ GtkLayerShell.Edge.RIGHT,
+ GtkLayerShell.Edge.BOTTOM,
+ GtkLayerShell.Edge.LEFT,
+ ]
+ if GtkLayerShell.get_anchor(self, x)
+ )
+
+ @anchor.setter
+ def anchor(self, value: str | Iterable[GtkLayerShell.Edge]) -> None:
+ self._anchor = value
+ if isinstance(value, (list, tuple)) and all(
+ isinstance(edge, GtkLayerShell.Edge) for edge in value
+ ):
+ for edge in [
+ GtkLayerShell.Edge.TOP,
+ GtkLayerShell.Edge.RIGHT,
+ GtkLayerShell.Edge.BOTTOM,
+ GtkLayerShell.Edge.LEFT,
+ ]:
+ if edge not in value:
+ GtkLayerShell.set_anchor(self, edge, False)
+ GtkLayerShell.set_anchor(self, edge, True)
+ return
+ elif isinstance(value, str):
+ for edge, anchored in WaylandWindow.extract_edges_from_string(
+ value
+ ).items():
+ GtkLayerShell.set_anchor(self, edge, anchored)
+
+ return
+
+ @Property(tuple[int, ...], flags="read-write")
+ def margin(self) -> tuple[int, ...]:
+ return tuple(
+ GtkLayerShell.get_margin(self, x)
+ for x in [
+ GtkLayerShell.Edge.TOP,
+ GtkLayerShell.Edge.RIGHT,
+ GtkLayerShell.Edge.BOTTOM,
+ GtkLayerShell.Edge.LEFT,
+ ]
+ )
+
+ @margin.setter
+ def margin(self, value: str | Iterable[int]) -> None:
+ for edge, mrgv in WaylandWindow.extract_margin(value).items():
+ GtkLayerShell.set_margin(self, edge, mrgv)
+ return
+
+ @Property(object, "read-write")
+ def keyboard_mode(self):
+ kb_mode = GtkLayerShell.get_keyboard_mode(self)
+ if GtkLayerShell.get_keyboard_interactivity(self):
+ kb_mode = GtkLayerShell.KeyboardMode.EXCLUSIVE
+ return kb_mode
+
+ @keyboard_mode.setter
+ def keyboard_mode(
+ self,
+ value: Literal["none", "exclusive", "on-demand"] | GtkLayerShell.KeyboardMode,
+ ):
+ return GtkLayerShell.set_keyboard_mode(
+ self,
+ get_enum_member(
+ GtkLayerShell.KeyboardMode,
+ value,
+ default=GtkLayerShell.KeyboardMode.NONE,
+ ),
+ )
+
+ def __init__(
+ self,
+ layer: Literal["background", "bottom", "top", "overlay"]
+ | GtkLayerShell.Layer = GtkLayerShell.Layer.TOP,
+ anchor: str = "",
+ margin: str | Iterable[int] = "0px 0px 0px 0px",
+ exclusivity: Literal["auto", "normal", "none"]
+ | WaylandWindowExclusivity = WaylandWindowExclusivity.NONE,
+ keyboard_mode: Literal["none", "exclusive", "on-demand"]
+ | GtkLayerShell.KeyboardMode = GtkLayerShell.KeyboardMode.NONE,
+ pass_through: bool = False,
+ monitor: int | Gdk.Monitor | None = None,
+ title: str = "fabric",
+ type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,
+ child: Gtk.Widget | None = None,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Window.__init__(
+ self,
+ title,
+ type,
+ child,
+ pass_through,
+ name,
+ False,
+ False,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ self._layer = GtkLayerShell.Layer.ENTRY_NUMBER
+ self._keyboard_mode = GtkLayerShell.KeyboardMode.NONE
+ self._anchor = anchor
+ self._exclusivity = WaylandWindowExclusivity.NONE
+
+ GtkLayerShell.init_for_window(self)
+ GtkLayerShell.set_namespace(self, title)
+ self.connect(
+ "notify::title",
+ lambda *_: GtkLayerShell.set_namespace(self, self.get_title()),
+ )
+ if monitor is not None:
+ self.monitor = monitor
+ self.layer = layer
+ self.anchor = anchor
+ self.margin = margin
+ self.keyboard_mode = keyboard_mode
+ self.exclusivity = exclusivity
+ self.show_all() if all_visible is True else self.show() if visible is True else None
+
+ def steal_input(self) -> None:
+ return GtkLayerShell.set_keyboard_interactivity(self, True)
+
+ def return_input(self) -> None:
+ return GtkLayerShell.set_keyboard_interactivity(self, False)
+
+ @staticmethod
+ def extract_anchor_values(string: str) -> tuple[str, ...]:
+ """
+ extracts the geometry values from a given geometry string.
+
+ :param string: the string containing the geometry values.
+ :type string: str
+ :return: a list of unique directions extracted from the geometry string.
+ :rtype: list
+ """
+ direction_map = {"l": "left", "t": "top", "r": "right", "b": "bottom"}
+ pattern = re.compile(r"\b(left|right|top|bottom)\b", re.IGNORECASE)
+ matches = pattern.findall(string)
+ return tuple(set(tuple(direction_map[match.lower()[0]] for match in matches)))
+
+ @staticmethod
+ def extract_edges_from_string(string: str) -> dict["GtkLayerShell.Edge", bool]:
+ anchor_values = WaylandWindow.extract_anchor_values(string.lower())
+ return {
+ GtkLayerShell.Edge.TOP: "top" in anchor_values,
+ GtkLayerShell.Edge.RIGHT: "right" in anchor_values,
+ GtkLayerShell.Edge.BOTTOM: "bottom" in anchor_values,
+ GtkLayerShell.Edge.LEFT: "left" in anchor_values,
+ }
+
+ @staticmethod
+ def extract_margin(input: str | Iterable[int]) -> dict["GtkLayerShell.Edge", int]:
+ margins = (
+ extract_css_values(input.lower())
+ if isinstance(input, str)
+ else input
+ if isinstance(input, (tuple, list)) and len(input) == 4
+ else (0, 0, 0, 0)
+ )
+ return {
+ GtkLayerShell.Edge.TOP: margins[0],
+ GtkLayerShell.Edge.RIGHT: margins[1],
+ GtkLayerShell.Edge.BOTTOM: margins[2],
+ GtkLayerShell.Edge.LEFT: margins[3],
+ }
diff --git a/fabric/fabric/widgets/webview/__init__.py b/fabric/fabric/widgets/webview/__init__.py
new file mode 100644
index 0000000..7894c5a
--- /dev/null
+++ b/fabric/fabric/widgets/webview/__init__.py
@@ -0,0 +1,3 @@
+from .webview import WebView
+
+__all__ = ["WebView"]
diff --git a/fabric/fabric/widgets/webview/javascript_bridge.js b/fabric/fabric/widgets/webview/javascript_bridge.js
new file mode 100644
index 0000000..f2b4c79
--- /dev/null
+++ b/fabric/fabric/widgets/webview/javascript_bridge.js
@@ -0,0 +1,106 @@
+/*
+ fabric javascript<->python bridge core
+ a bridging layer between python and javascript for GtkWebKit
+*/
+
+/**
+ * issue a bridge call to fabric
+ * @param {string} function_name
+ * @param {array} args
+ * @param {number} id
+ * @returns {any}
+ */
+function doInvokeWebkitBridgeCall(function_name, args, id) {
+ if (!window.webkit) {
+ throw new Error(
+ "window.webkit is not defined, can't call " + function_name
+ );
+ }
+ return window.webkit.messageHandlers.fabricJsBridge.postMessage(
+ JSON.stringify([function_name, args, id])
+ );
+}
+
+window.fabric = {
+ _init_called: false,
+ _pool: {},
+ bridge: {},
+ init: () => {
+ if (window.fabric._init_called) {
+ return;
+ }
+ window.fabric._init_called = true;
+ window.dispatchEvent(new CustomEvent("bridgeReady"));
+ },
+
+ /**
+ * create a bridge object for a foreign python function
+ * @param {Map} func_list
+ * {func: string, args: Array}
+ */
+ createBridge: (func_list) => {
+ for (var i = 0; i < func_list.length; i++) {
+ var element = func_list[i];
+ var func_name = element.func;
+ var args = element.args;
+ var func_hierarchy = func_name.split(".");
+ var function_name = func_hierarchy.pop();
+ var nested_object = func_hierarchy.reduce((obj, prop) => {
+ if (!obj[prop]) {
+ obj[prop] = {};
+ }
+ return obj[prop];
+ }, window.fabric.bridge);
+ var func_body = `
+ var __id__ = (Math.random() + "").substring(2);
+
+ doInvokeWebkitBridgeCall("${func_name}", arguments, __id__);
+ var promise = new Promise(
+ function(resolve, reject) {
+ window.fabric.checkReturnValue("${func_name}", resolve, reject, __id__);
+ }
+ );
+
+ return promise;
+ `;
+ nested_object[function_name] = new Function(args, func_body);
+ window.fabric._pool[func_name] = {};
+ }
+ },
+
+ /**
+ * check the return value of a foreign function
+ * @param {string} func_name
+ * @param {function} resolve
+ * @param {function} reject
+ * @param {number} id
+ */
+ checkReturnValue: (func_name, resolve, reject, id) => {
+ // this is not a good idea, but it works anyways
+ // a better way to implement this is to use a state managed model
+ // TODO: the above...
+ var check = setInterval(function () {
+ var return_obj = window.fabric._pool[func_name][id];
+ if (return_obj) {
+ var value = return_obj.value;
+ var error = return_obj.error;
+
+ delete window.fabric._pool[func_name][id];
+ clearInterval(check);
+
+ if (error) {
+ var python_error = JSON.parse(value);
+ var error = new Error(python_error.message);
+ error.name = python_error.name;
+ error.stack = python_error.stack;
+
+ reject(error);
+ } else {
+ resolve(JSON.parse(value));
+ }
+ }
+ }, 1);
+ },
+};
+
+window.fabric.init();
diff --git a/fabric/fabric/widgets/webview/webview.py b/fabric/fabric/widgets/webview/webview.py
new file mode 100644
index 0000000..fac7e34
--- /dev/null
+++ b/fabric/fabric/widgets/webview/webview.py
@@ -0,0 +1,279 @@
+import gi
+import json
+import inspect
+import traceback
+from loguru import logger
+from collections.abc import Iterable, Callable
+from typing import Literal, overload
+from fabric.widgets.widget import Widget
+from fabric.utils.helpers import get_relative_path
+
+gi.require_version("Gtk", "3.0")
+gi.require_version("WebKit2", "4.1")
+from gi.repository import Gtk, GLib, WebKit2
+
+
+def get_javascript_bridge_code() -> str:
+ with open(get_relative_path("./javascript_bridge.js"), "r") as f:
+ return f.read()
+
+
+JAVASCRIPT_BRIDGE_CODE = get_javascript_bridge_code()
+
+
+class JavaScriptBridge:
+ def __init__(
+ self,
+ webview: "WebView",
+ content_manager: WebKit2.UserContentManager,
+ *args,
+ **kwargs,
+ ):
+ super().__init__(*args, **kwargs)
+ self._registered_functions: dict[str, Callable] = {}
+ self._bridge_initialized: bool = False
+
+ self.webview = webview
+ self.content_manager = content_manager
+ self.content_manager.register_script_message_handler("fabricJsBridge")
+ self.content_manager.connect("script-message-received", self.on_javascript_call)
+ self.webview.connect("load_changed", self.on_load_finish) # odd signal name
+
+ def on_load_finish(self, _: WebKit2.WebView, status: WebKit2.LoadEvent):
+ # inject javascript bridge as soon as the page is loaded
+ if status == WebKit2.LoadEvent.FINISHED:
+ self.webview.run_javascript(
+ JAVASCRIPT_BRIDGE_CODE,
+ callback=lambda *args: [
+ self.expose_function(
+ func, func_name
+ ) # handle unregistered functions (exposed before bridge init)
+ for func_name, func in self._registered_functions.items()
+ ],
+ )
+ self._bridge_initialized = True
+ return
+
+ def expose_function(self, func: Callable, func_name: str | None = None):
+ name = func_name or func.__name__
+ self._registered_functions[name] = func
+ args = list(inspect.getfullargspec(func).args)
+ if self._bridge_initialized:
+ return self.webview.run_javascript(
+ f"window.fabric.createBridge([{{func: '{name}', args: {args}}}])"
+ )
+ return # on_load_finish should handle this
+
+ def on_javascript_call(
+ self,
+ _: WebKit2.UserContentManager,
+ js_result: WebKit2.JavascriptResult,
+ ):
+ func_name, func_args, value_id = json.loads(
+ js_result.get_js_value().to_string() # a serialized json string is what we expect
+ )
+ func_name: str
+ func_args: dict[str, str]
+ value_id: int
+ func = self._registered_functions.get(func_name)
+ if not func:
+ return logger.warning(
+ f"[WebView][JavaScriptBridge] javascript is trying to call a function ({func_name}) that doesn't exist"
+ )
+ try:
+ GLib.Thread.new(
+ "call-from-js",
+ self.do_javascript_callback,
+ func_name,
+ func,
+ func_args,
+ value_id,
+ )
+ except Exception as e:
+ logger.warning(
+ f"[WebView][JavaScriptBridge] can't create a thread for calling a function {func_name}: {e}"
+ )
+
+ def do_javascript_callback(
+ self, func_name: str, func: Callable, args: dict[str, str], value_id: int
+ ):
+ logger.debug(
+ f"[WebView][JavaScriptBridge] calling python function from javascript, function name {func_name} with args {args}"
+ )
+ try:
+ result = func(*args.values())
+ result = json.dumps(result).replace("\\", "\\\\").replace("'", "\\'")
+ code = f'window.fabric._pool["{func_name}"]["{value_id}"] = {{value: \'{result}\'}}'
+ except Exception as e:
+ print(traceback.format_exc())
+ error = {
+ "message": str(e),
+ "name": type(e).__name__,
+ "stack": traceback.format_exc(),
+ }
+ result = json.dumps(error).replace("\\", "\\\\").replace("'", "\\'")
+ code = f"window.fabric._pool['{func_name}']['{value_id}'] = {{error: true, value: '{result}'}}"
+ return self.webview.run_javascript(code)
+
+
+class WebView(WebKit2.WebView, Widget):
+ """
+ a widget for loading a web page, it can load local and remote pages
+ it provides a bridge to communicate with python from javascript (and vice versa)
+ ---
+ ## `.bridge`
+ this object will hold a field for interacting with the javascript
+ bridge, the field name is `bridge`, it will be set only if you set `open_bridge` to `True`
+ otherwise it will be set to `None`
+
+ ## exposing functions
+ to expose a python function you can use the method `expose_function` which's a member of the `bridge` field
+
+ ### security warning
+ using the bridge with non-local pages is not recommended for security reasons
+ """
+
+ @overload
+ def __init__(
+ self,
+ url: None = None,
+ html: None = None,
+ open_bridge: bool = True,
+ open_inspector: bool = False,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+ @overload
+ def __init__(
+ self,
+ url: str | None = None,
+ html: None = None,
+ open_bridge: bool = True,
+ open_inspector: bool = False,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+ @overload
+ def __init__(
+ self,
+ url: None = None,
+ html: str | None = None,
+ open_bridge: bool = True,
+ open_inspector: bool = False,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ): ...
+
+ def __init__(
+ self,
+ url: str | None = None,
+ html: str | None = None,
+ open_bridge: bool = True,
+ open_inspector: bool = False,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ WebKit2.WebView.__init__(
+ self, # type: ignore
+ user_content_manager=WebKit2.UserContentManager(),
+ )
+ Widget.__init__(
+ self,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+ if url is not None and html is not None:
+ raise ValueError("you can't pass both url and html")
+
+ self.bridge = (
+ JavaScriptBridge(self, self.get_user_content_manager())
+ if open_bridge is True
+ else None
+ )
+
+ if url is not None:
+ if self.bridge is not None and url.startswith(("http://", "https://")):
+ logger.warning(
+ "[WebView] it's not generally a good idea to expose a javascript interface for the world wide web, consider closing the javascript bridge"
+ )
+ self.load_uri(url)
+ elif html is not None:
+ self.load_html(html, None)
+
+ self.get_settings().set_enable_developer_extras(True)
+ if open_inspector is True:
+ self.open_inspector()
+
+ def open_inspector(self):
+ self.get_inspector().show()
diff --git a/fabric/fabric/widgets/widget.py b/fabric/fabric/widgets/widget.py
new file mode 100644
index 0000000..c9a2260
--- /dev/null
+++ b/fabric/fabric/widgets/widget.py
@@ -0,0 +1,309 @@
+import gi
+from typing import overload, Literal
+from fabric.utils import compile_css, get_enum_member
+from fabric.core.service import Service, Property
+from collections.abc import Iterable
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk, GdkPixbuf
+
+# shhhh
+import logging
+
+logging.captureWarnings(True)
+logging.getLogger("gi.overrides").setLevel(logging.ERROR)
+
+
+CURSOR_TYPE = Literal[
+ "default",
+ "help",
+ "pointer",
+ "context-menu",
+ "progress",
+ "wait",
+ "cell",
+ "crosshair",
+ "text",
+ "vertical-text",
+ "alias",
+ "copy",
+ "no-drop",
+ "move",
+ "not-allowed",
+ "grab",
+ "grabbing",
+ "all-scroll",
+ "col-resize",
+ "row-resize",
+ "n-resize",
+ "e-resize",
+ "s-resize",
+ "w-resize",
+ "ne-resize",
+ "nw-resize",
+ "sw-resize",
+ "se-resize",
+ "ew-resize",
+ "ns-resize",
+ "nesw-resize",
+ "nwse-resize",
+ "zoom-in",
+ "zoom-out",
+]
+
+EVENT_TYPE = Literal[
+ "exposure",
+ "pointer-motion",
+ "pointer-motion-hint",
+ "button-motion",
+ "button-1-motion",
+ "button-2-motion",
+ "button-3-motion",
+ "button-press",
+ "button-release",
+ "key-press",
+ "key-release",
+ "enter-notify",
+ "leave-notify",
+ "focus-change",
+ "structure",
+ "property-change",
+ "visibility-notify",
+ "proximity-in",
+ "proximity-out",
+ "substructure",
+ "scroll",
+ "touch",
+ "smooth-scroll",
+ "touchpad-gesture",
+ "tablet-pad",
+ "all",
+]
+
+
+class Widget(Gtk.Widget, Service):
+ """the base widget, all other widgets should inherit from this class"""
+
+ @Property(Gtk.Align, "read-write", default_value=Gtk.Align.BASELINE)
+ def v_align(self):
+ return self.get_valign()
+
+ @v_align.setter
+ def v_align(
+ self, value: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align
+ ):
+ self.set_valign(get_enum_member(Gtk.Align, value, {}, Gtk.Align.BASELINE))
+
+ @Property(Gtk.Align, "read-write", default_value=Gtk.Align.BASELINE)
+ def h_align(self):
+ return self.get_halign()
+
+ @h_align.setter
+ def h_align(
+ self, value: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align
+ ):
+ self.set_halign(get_enum_member(Gtk.Align, value, {}, Gtk.Align.BASELINE))
+
+ @Property(bool, default_value=False)
+ def v_expand(self):
+ return self.get_vexpand()
+
+ @v_expand.setter
+ def v_expand(self, value: bool):
+ return self.set_vexpand(value)
+
+ @Property(bool, default_value=False)
+ def h_expand(self):
+ return self.get_hexpand()
+
+ @h_expand.setter
+ def h_expand(self, value: bool):
+ return self.set_hexpand(value)
+
+ @Property(list[str], "read-write")
+ def style_classes(self) -> list[str]:
+ return self.get_style_context().list_classes() or []
+
+ @style_classes.setter
+ def style_classes(self, classes: Iterable[str] | str) -> None:
+ self.remove_style_class(self.style_classes)
+ self.add_style_class(classes)
+ return
+
+ def __init__(
+ self,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Gtk.Widget.__init__(self) # type: ignore
+ Service.__init__(self, **kwargs)
+
+ self._style_provider: Gtk.CssProvider | None = None
+ self._cursor: Gdk.Cursor | None = None
+
+ self.set_name(name) if name is not None else None
+ self.set_tooltip_text(tooltip_text) if tooltip_text is not None else None
+ self.set_tooltip_markup(tooltip_markup) if tooltip_markup is not None else None
+
+ if v_align:
+ self.v_align = v_align
+ if h_align:
+ self.h_align = h_align
+
+ self.v_expand = v_expand
+ self.h_expand = h_expand
+
+ if size is not None:
+ self.set_size_request(*((size, size) if isinstance(size, int) else size))
+
+ if style_classes:
+ self.add_style_class(style_classes) # to not override default classes
+
+ if style:
+ self.set_style(style)
+
+ self.show_all() if all_visible is True else self.show() if visible is True else None
+
+ def set_alignment(
+ self,
+ orientation: Literal["v", "vertical", "h", "horizontal"] | Gtk.Orientation,
+ alignment: Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align,
+ ):
+ ornt = get_enum_member(
+ Gtk.Orientation, orientation, {"v": "vertical", "h": "horizontal"}
+ )
+ align = get_enum_member(Gtk.Align, alignment)
+ return (
+ self.set_valign if ornt == Gtk.Orientation.VERTICAL else self.set_halign
+ )(align)
+
+ def set_style(
+ self,
+ style: str,
+ compile: bool = True,
+ append: bool = False,
+ add_brackets: bool = True,
+ ) -> None:
+ """
+ set the styles for this widget from a string
+
+ :param style: the css style
+ :type style: str
+ :param compile: to compile the style, defaults to True
+ :type compile: bool, optional
+ :param append: appends this style to other loaded styles (if any), if this is set to False it will clear all other styles before applying, defaults to False
+ :type append: bool, optional
+ :param add_brackets: add brackets to the style if they are missing (e.g. `padding: 10; something-useful: unset;` was passed; will be converted to `* { padding: 10; something-useful: unset; }`), defaults to True
+ """
+ style = (
+ f"* {{ {style} }}"
+ if "{" not in style or "}" not in style and add_brackets is True
+ else style
+ )
+ style = compile_css(style) if compile is True else style
+
+ self.get_style_context().remove_provider(
+ self._style_provider
+ ) if self._style_provider is not None and append is False else None
+
+ self._style_provider = Gtk.CssProvider()
+ self._style_provider.load_from_data(style.encode()) # type: ignore
+ self.get_style_context().add_provider(
+ self._style_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
+ )
+ return
+
+ @overload
+ def set_cursor(
+ self,
+ cursor: CURSOR_TYPE | Gdk.CursorType | Gdk.Cursor | None,
+ pixbuf: None = None,
+ x_offset: int = 0,
+ y_offset: int = 0,
+ ): ...
+
+ @overload
+ def set_cursor(
+ self,
+ cursor: None,
+ pixbuf: GdkPixbuf.Pixbuf | None = None,
+ x_offset: int = 0,
+ y_offset: int = 0,
+ ): ...
+
+ def set_cursor(
+ self,
+ cursor: CURSOR_TYPE | Gdk.CursorType | Gdk.Cursor | None,
+ pixbuf: GdkPixbuf.Pixbuf | None = None,
+ x_offset: int = 0,
+ y_offset: int = 0,
+ ):
+ display = Gdk.Display.get_default()
+ window = self.get_window()
+ if display is None or window is None:
+ raise RuntimeError(
+ f"can't set new cursor, one of `display` or `window` is None ({display}, {window})"
+ )
+ elif not self.is_hovered():
+ return window.set_cursor(Gdk.Cursor.new_from_name(display, "default"))
+
+ if pixbuf is not None:
+ cursor = Gdk.Cursor.new_from_pixbuf(display, pixbuf, x_offset, y_offset)
+ elif isinstance(cursor, Gdk.Cursor):
+ pass
+ elif isinstance(cursor, str):
+ cursor = Gdk.Cursor.new_from_name(display, cursor)
+ elif isinstance(cursor, Gdk.CursorType):
+ cursor = Gdk.Cursor.new_for_display(display, cursor)
+ else:
+ cursor = Gdk.Cursor.new_from_name(display, "default")
+
+ return window.set_cursor(cursor)
+
+ def is_hovered(self, event: Gdk.EventAny | None = None) -> bool:
+ x, y = self.get_pointer() # type: ignore
+ allocation = self.get_allocation()
+ if event:
+ x, y = event.get_coords() # type: ignore
+ return 0 < x < allocation.width and 0 < y < allocation.height # type: ignore
+
+ def add_style_class(self, class_name: str | Iterable[str]):
+ if isinstance(class_name, str):
+ class_name = class_name.strip().lstrip().split()
+ for klass in class_name:
+ self.get_style_context().add_class(klass)
+ return
+
+ def remove_style_class(self, class_name: str | Iterable[str]):
+ if isinstance(class_name, str):
+ class_name = class_name.strip().lstrip().split()
+ for klass in class_name:
+ self.get_style_context().remove_class(klass)
+ return
+
+ def add_events(
+ self, events: EVENT_TYPE | Gdk.EventMask | Iterable[EVENT_TYPE | Gdk.EventMask]
+ ):
+ _events: int = 0
+ events_map: dict[str, str] = {
+ x: (x if x != "all" else "all-events") + "-mask"
+ for x in EVENT_TYPE.__args__
+ }
+ for event in (events,) if not isinstance(events, (tuple, list)) else events:
+ _events |= get_enum_member(Gdk.EventMask, event, events_map, 0) # type: ignore
+ return super().add_events(_events)
diff --git a/fabric/fabric/widgets/window.py b/fabric/fabric/widgets/window.py
new file mode 100644
index 0000000..5a95845
--- /dev/null
+++ b/fabric/fabric/widgets/window.py
@@ -0,0 +1,163 @@
+import gi
+import cairo
+from loguru import logger
+from typing import Literal, Self, Any
+from collections.abc import Callable, Iterable
+from fabric.core.service import Property
+from fabric.core.application import Application
+from fabric.utils.helpers import get_enum_member, bulk_replace
+from fabric.widgets.container import Container
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, GLib
+
+
+class Window(Gtk.Window, Container):
+ @Property(Application, install=False)
+ def application(self) -> Application:
+ return self.get_application() # type: ignore
+
+ @Property(bool, "read-write", default_value=False)
+ def pass_through(self) -> bool:
+ return self._pass_through
+
+ @pass_through.setter
+ def pass_through(self, pass_through: bool = False):
+ self._pass_through = pass_through
+
+ self.input_shape_combine_region(
+ cairo.Region(cairo.RectangleInt()) if pass_through is True else None
+ )
+
+ return
+
+ def __init__(
+ self,
+ title: str = "fabric",
+ type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,
+ child: Gtk.Widget | None = None,
+ pass_through: bool = False,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ self._pass_through = pass_through
+
+ Gtk.Window.__init__(
+ self, # type: ignore
+ title=title,
+ type=get_enum_member(
+ Gtk.WindowType, type, {"top-level": "toplevel"}, Gtk.WindowType.TOPLEVEL
+ ),
+ )
+ Container.__init__(
+ self,
+ child,
+ name,
+ visible,
+ all_visible,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ None,
+ **kwargs,
+ )
+
+ self.set_default_size(
+ *((size, size) if isinstance(size, int) else size)
+ ) if size is not None else None
+
+ self._key_press_handler: int = 0
+ self._keybinding_handlers: dict[
+ str, list[tuple[Callable[[Self, Any], Any], int]]
+ ] = {}
+
+ self.pass_through = pass_through
+
+ def do_handle_key_press_event(self, _, event):
+ if not (
+ keybind_entry := self._keybinding_handlers.get(
+ " ".join(
+ bulk_replace(
+ Gtk.accelerator_name(event.keyval, event.state).strip(),
+ ["", "", "", "", ""],
+ [" ", "Shift ", "Ctrl ", "Super ", "Alt "],
+ ).split()
+ )
+ )
+ ):
+ return
+ for kbh in keybind_entry:
+ kbh[0](self, event)
+ return
+
+ def do_post_kebinding_removal(self):
+ for kbn, kbd in self._keybinding_handlers.copy().items():
+ if not kbd:
+ self._keybinding_handlers.pop(kbn)
+ if not self._keybinding_handlers:
+ self.disconnect_by_func(self.do_handle_key_press_event)
+ self._key_press_handler = 0
+ return
+
+ def add_keybinding(self, keybind: str, callback: Callable[[Self, Any], Any]) -> int:
+ handler = GLib.random_int()
+
+ keybind_entry = self._keybinding_handlers.setdefault(keybind, [])
+ keybind_entry.append((callback, handler))
+ if not self._key_press_handler:
+ self._key_press_handler = self.connect(
+ "key-press-event", self.do_handle_key_press_event
+ )
+
+ return handler
+
+ def remove_keybinding(self, reference: int | Callable | str):
+ if isinstance(reference, (int, Callable)):
+ for kbn, kbd in self._keybinding_handlers.items():
+ for bindref in kbd:
+ if reference in bindref:
+ kbd.remove(bindref)
+ return self.do_post_kebinding_removal()
+ self._keybinding_handlers.pop(reference, None)
+ return self.do_post_kebinding_removal()
+
+ # overrides
+ def do_handle_post_show_request(self) -> None:
+ if not self.get_children():
+ logger.warning(
+ "[Window] showing an empty window is not recommended, some compositors might freak out."
+ )
+ return
+
+ def show(self):
+ super().show()
+ return self.do_handle_post_show_request()
+
+ def show_all(self):
+ super().show_all()
+ return self.do_handle_post_show_request()
+
+ def do_size_allocate(self, alloc):
+ Gtk.Window.do_size_allocate(self, alloc) # type: ignore
+
+ return self.set_pass_through(self._pass_through)
diff --git a/fabric/fabric/widgets/x11.py b/fabric/fabric/widgets/x11.py
new file mode 100644
index 0000000..25f7558
--- /dev/null
+++ b/fabric/fabric/widgets/x11.py
@@ -0,0 +1,351 @@
+import gi
+from enum import Enum
+from loguru import logger
+from collections.abc import Iterable
+from typing import no_type_check, Literal
+from fabric.core.service import Property
+from fabric.widgets.window import Window
+from fabric.utils import get_enum_member, extract_css_values, idle_add
+
+gi.require_version("Gtk", "3.0")
+from gi.repository import Gtk, Gdk
+
+try:
+ from Xlib import X as XServer
+ from Xlib.display import Display as XDisplay
+ from Xlib.xobject.drawable import Window as XWindow
+except Exception:
+ logger.warning(
+ "using Fabric under X11 might require extra dependencies for extra features, consider installing the python package Xlib"
+ )
+
+
+class X11WindowLayer(Enum):
+ TOP = 1
+ BOTTOM = 2
+
+
+class X11WindowGeometry(Enum):
+ CENTER = 1
+ CENTER_AUTO = 2
+ TOP = 3
+ TOP_LEFT = 4
+ TOP_RIGHT = 5
+ BOTTOM = 6
+ BOTTOM_LEFT = 7
+ BOTTOM_RIGHT = 8
+ LEFT = 9
+ RIGHT = 10
+
+
+class X11Window(Window):
+ # it's nothing fancy, but we have to work with what we have (so far)
+ """
+ a dockable window for X11/Xorg
+
+ ### NOTE
+ none of the properties this window takes is guaranteed to work
+ since EMWH is not a standard protocol across all window managers
+ """
+
+ @Property(X11WindowLayer, "read-write")
+ def layer(self) -> X11WindowLayer:
+ return self._layer
+
+ @layer.setter
+ def layer(self, value: Literal["top", "bottom"] | X11WindowLayer):
+ self._layer = get_enum_member(X11WindowLayer, value, default=X11WindowLayer.TOP)
+ self.do_dispatch_layer()
+ return
+
+ @Property(tuple, "read-write")
+ def margin(self) -> tuple[int, int, int, int]:
+ return self._margin
+
+ @margin.setter
+ def margin(self, value: str | Iterable[int]):
+ # (top, right, bottom, left)
+ self._margin = (
+ extract_css_values(value)
+ if isinstance(value, str)
+ else value
+ if isinstance(value, (tuple, list)) and len(value) == 4
+ else (0, 0, 0, 0)
+ ) # type: ignore
+ self.do_dispatch_geometry()
+ return
+
+ @Property(X11WindowGeometry, "read-write")
+ def geometry(self) -> X11WindowGeometry:
+ return self._geometry
+
+ @geometry.setter
+ def geometry(
+ self,
+ value: Literal[
+ "center",
+ "center-auto",
+ "top-left",
+ "top",
+ "top-right",
+ "left",
+ "right",
+ "bottom-left",
+ "bottom",
+ "bottom-right",
+ ]
+ | X11WindowGeometry,
+ ):
+ self._geometry = get_enum_member(
+ X11WindowGeometry, value, default=X11WindowGeometry.TOP
+ )
+ if self._geometry in (X11WindowGeometry.CENTER, X11WindowGeometry.CENTER_AUTO):
+ # don't use our way to handle window centering, gdk can do that for us
+ self.handler_disconnect(
+ self._size_allocate_hook
+ ) if self._size_allocate_hook is not None else None
+ self.set_position(Gtk.WindowPosition.CENTER_ALWAYS)
+ else:
+ self.do_dispatch_geometry()
+ if not self._size_allocate_hook:
+ self._size_allocate_hook = self.connect(
+ "size-allocate", lambda _, __: self.do_dispatch_geometry()
+ ) # type: ignore
+ return
+
+ def __init__(
+ self,
+ type_hint: Literal[
+ "normal",
+ "dialog",
+ "menu",
+ "toolbar",
+ "splashscreen",
+ "utility",
+ "dock",
+ "desktop",
+ "dropdown-menu",
+ "popup-menu",
+ "tooltip",
+ "notification",
+ "combo",
+ "dnd",
+ ]
+ | Gdk.WindowTypeHint = Gdk.WindowTypeHint.DOCK,
+ geometry: Literal[
+ "center",
+ "center-auto",
+ "top-left",
+ "top",
+ "top-right",
+ "left",
+ "right",
+ "bottom-left",
+ "bottom",
+ "bottom-right",
+ ]
+ | X11WindowGeometry = X11WindowGeometry.TOP,
+ margin: str | Iterable[int] = "0px 0px 0px 0px",
+ layer: Literal["top", "bottom"] | X11WindowLayer = X11WindowLayer.TOP,
+ sticky: bool = True,
+ focusable: bool = True,
+ resizable: bool = False,
+ decorated: bool = False,
+ taskbar_hint: bool = False,
+ pager_hint: bool = False,
+ title: str = "fabric",
+ type: Literal["top-level", "popup"] | Gtk.WindowType = Gtk.WindowType.TOPLEVEL,
+ child: Gtk.Widget | None = None,
+ pass_through: bool = False,
+ name: str | None = None,
+ visible: bool = True,
+ all_visible: bool = False,
+ style: str | None = None,
+ style_classes: Iterable[str] | str | None = None,
+ tooltip_text: str | None = None,
+ tooltip_markup: str | None = None,
+ h_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ v_align: Literal["fill", "start", "end", "center", "baseline"]
+ | Gtk.Align
+ | None = None,
+ h_expand: bool = False,
+ v_expand: bool = False,
+ size: Iterable[int] | int | None = None,
+ **kwargs,
+ ):
+ Window.__init__(
+ self,
+ title,
+ type,
+ child,
+ pass_through,
+ name,
+ False,
+ False,
+ style,
+ style_classes,
+ tooltip_text,
+ tooltip_markup,
+ h_align,
+ v_align,
+ h_expand,
+ v_expand,
+ size,
+ **kwargs,
+ )
+
+ self._margin: tuple[int, int, int, int] = (0, 0, 0, 0)
+ self._layer = X11WindowLayer.TOP
+ self._geometry = X11WindowGeometry.TOP
+ self._size_allocate_hook: int | None = None
+
+ self._display: Gdk.Display
+ self._rectangle: Gdk.Rectangle
+ self._scale_factor: int
+
+ # for extra functionality
+ self._xdisplay: "XDisplay | None" = None
+ self._xwindow: "XWindow | None" = None
+ self._xid: int = 0
+
+ self.set_type_hint(
+ get_enum_member(
+ Gdk.WindowTypeHint, type_hint, default=Gdk.WindowTypeHint.DOCK
+ )
+ )
+ self.set_accept_focus(focusable)
+ self.set_skip_taskbar_hint(not taskbar_hint)
+ self.set_skip_pager_hint(not pager_hint)
+ self.set_decorated(decorated)
+ self.set_resizable(resizable)
+ self.stick() if sticky else self.unstick()
+ # all aboard...
+ self.do_initialize()
+ self.do_initialize_x_backend()
+
+ self.layer = layer
+ self.margin = margin
+ self.geometry = geometry
+
+ self.show_all() if all_visible is True else self.show() if visible is True else None
+
+ def do_initialize(self):
+ self._display, self._rectangle, self._scale_factor = self.do_get_display_props()
+ visual = self._display.get_default_screen().get_rgba_visual()
+ self.set_visual(visual) # to get alpha w/o a X11 compositor
+ self.set_app_paintable(True)
+ return
+
+ def do_initialize_x_backend(self):
+ try:
+ self._xdisplay = XDisplay()
+ except Exception:
+
+ def requires_xlib(*_):
+ raise RuntimeError(
+ "this method requires the python package `Xlib` to be installed"
+ )
+
+ self.steal_input_soft = requires_xlib # type: ignore
+ return
+
+ def on_draw(*_):
+ # as of my test's results, the draw signal is
+ # the only signal emitted when the window is fully realized
+ # and that's needed so we don't face a unexpected behavior later
+ self._xid = self.get_window().get_xid() # type: ignore
+ self._xwindow = self._xdisplay.create_resource_object("window", self._xid) # type: ignore
+ # thank you, goodbye!
+ self.disconnect_by_func(on_draw)
+
+ self.connect("draw", on_draw)
+
+ def steal_input(self) -> bool:
+ if not (win := self.get_window()):
+ return False
+ idle_add(lambda *_: Gdk.keyboard_grab(win, False, Gdk.CURRENT_TIME))
+ return True
+
+ def unsteal_input(self):
+ return idle_add(lambda *_: Gdk.keyboard_ungrab(Gdk.CURRENT_TIME))
+
+ def steal_input_soft(self, can_release: bool = False) -> bool:
+ if (
+ not (self.get_realized() and self.is_visible())
+ or not self._xdisplay
+ or not self._xwindow
+ ):
+ return False
+ self._xdisplay.set_input_focus(
+ self._xwindow, # type: ignore
+ XServer.RevertToPointerRoot if can_release else XServer.RevertToNone,
+ XServer.CurrentTime,
+ )
+ self._xdisplay.sync()
+ self._xdisplay.flush()
+
+ return True
+
+ def do_get_display_props(self) -> tuple[Gdk.Display, Gdk.Rectangle, int]:
+ display = Gdk.Display.get_default()
+ rectangle = display.get_primary_monitor().get_geometry()
+ scale_factor = display.get_primary_monitor().get_scale_factor()
+ return display, rectangle, scale_factor # type: ignore
+
+ def do_dispatch_layer(self):
+ self.set_keep_above(self._layer == X11WindowLayer.TOP)
+ self.set_keep_below(self._layer == X11WindowLayer.BOTTOM)
+ return
+
+ @no_type_check
+ def do_dispatch_geometry(self):
+ # move the window to match the asked geometry
+ # this requires us knowing the geometry and the margin
+ if self._rectangle is None:
+ self._display, self._rectangle, self._scale_factor = (
+ self.do_get_display_props()
+ )
+ aloc_size, natural_size = self.get_allocated_size()
+ aloc_width, aloc_height = aloc_size.width, aloc_size.height
+
+ x = y = 0
+ match self._geometry:
+ # case X11WindowGeometry.CENTER_AUTO:
+ # x = self._rectangle.width // 2 - aloc_width // 2
+ # y = self._rectangle.height // 2 - aloc_height // 2
+ case X11WindowGeometry.TOP:
+ y = 0
+ x = self._rectangle.width // 2 - aloc_width // 2
+ case X11WindowGeometry.TOP_LEFT:
+ x, y = 0, 0
+ case X11WindowGeometry.TOP_RIGHT:
+ y = 0
+ x = self._rectangle.width - aloc_width
+ case X11WindowGeometry.BOTTOM:
+ x = self._rectangle.width // 2 - aloc_width // 2
+ y = self._rectangle.height - aloc_height
+ case X11WindowGeometry.BOTTOM_LEFT:
+ x = 0
+ y = self._rectangle.height - aloc_height
+ case X11WindowGeometry.BOTTOM_RIGHT:
+ x = self._rectangle.width - aloc_width
+ y = self._rectangle.height - aloc_height
+ case X11WindowGeometry.LEFT:
+ x = 0
+ y = self._rectangle.height // 2 - aloc_height // 2
+ case X11WindowGeometry.RIGHT:
+ x = self._rectangle.width - aloc_width
+ y = self._rectangle.height // 2 - aloc_height // 2
+ case _:
+ return False
+
+ x_margin = self._margin[1] - self._margin[3]
+ y_margin = self._margin[0] - self._margin[2]
+ x += self._rectangle.x
+ y += self._rectangle.y
+ x = x + x_margin
+ y = y + y_margin
+
+ return self.move(x, y)
diff --git a/fabric/flake.lock b/fabric/flake.lock
new file mode 100644
index 0000000..92cd665
--- /dev/null
+++ b/fabric/flake.lock
@@ -0,0 +1,61 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1733261153,
+ "narHash": "sha256-eq51hyiaIwtWo19fPEeE0Zr2s83DYMKJoukNLgGGpek=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "b681065d0919f7eb5309a93cea2cfa84dec9aa88",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-24.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs",
+ "utils": "utils"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ },
+ "utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1731533236,
+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/fabric/flake.nix b/fabric/flake.nix
new file mode 100644
index 0000000..5ca9bf7
--- /dev/null
+++ b/fabric/flake.nix
@@ -0,0 +1,71 @@
+{
+ description = ''
+ next-gen framework for building desktop widgets using Python
+ (check the rewrite branch for progress)
+ '';
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
+ utils.url = "github:numtide/flake-utils";
+ };
+
+ outputs = {
+ nixpkgs,
+ utils,
+ ...
+ }:
+ utils.lib.eachDefaultSystem (
+ system: let
+ overlay = final: prev: {
+ pythonPackagesExtensions =
+ prev.pythonPackagesExtensions
+ ++ [
+ (python-final: python-prev: {
+ python-fabric = prev.callPackage ./default.nix {};
+ })
+ ];
+ };
+
+ pkgs = nixpkgs.legacyPackages.${system}.extend overlay;
+ in {
+ overlays.default = overlay;
+ formatter = pkgs.nixfmt-rfc-style;
+ packages = {
+ default = pkgs.python3Packages.python-fabric;
+ run-widget = pkgs.callPackage ./run-widget.nix {};
+ };
+
+ devShells = {
+ default = pkgs.mkShell {
+ name = "fabric-shell";
+ packages = with pkgs; [
+ ruff
+ gtk3
+ gtk-layer-shell
+ cairo
+ gobject-introspection
+ libdbusmenu-gtk3
+ gdk-pixbuf
+ gnome-bluetooth
+ cinnamon-desktop
+ (python3.withPackages (
+ ps:
+ with ps; [
+ setuptools
+ wheel
+ build
+ click
+ pycairo
+ pygobject3
+ pygobject-stubs
+ loguru
+ psutil
+ python-fabric
+ ]
+ ))
+ ];
+ };
+ };
+ }
+ );
+}
diff --git a/fabric/pyproject.toml b/fabric/pyproject.toml
new file mode 100644
index 0000000..d2ec09f
--- /dev/null
+++ b/fabric/pyproject.toml
@@ -0,0 +1,41 @@
+[project]
+name = "fabric"
+version = "0.0.2"
+dependencies = ["click", "loguru", "pycairo", "PyGObject==3.50.0"]
+requires-python = ">=3.11"
+authors = [
+ { name = "Yousef El-Darsh", email = "yousef.eldarsh@gmail.com" },
+ { name = "Kai Read", email = "ready@kairead.com" },
+]
+maintainers = [
+ { name = "Yousef El-Darsh", email = "yousef.eldarsh@gmail.com" },
+ { name = "Kai Read", email = "ready@kairead.com" },
+]
+description = "Next-Gen python framework for creating system widgets on *Nix systems!"
+readme = "README.md"
+keywords = [
+ "widgets",
+ "gtk",
+ "linux",
+ "pygobject",
+ "bar",
+ "statusbar",
+ "panel",
+ "dock",
+]
+classifiers = ["Programming Language :: Python"]
+
+[project.optional-dependencies]
+system-status = ["psutil"]
+
+[tool.setuptools.package-data]
+"*" = ["*.xml", "*.js"]
+
+[project.urls]
+"Website" = "https://ffpy.org"
+"Documentation" = "https://wiki.ffpy.org"
+"Website (mirror)" = "https://fabric-widgets.org"
+"Documentation (mirror)" = "https://wiki.fabric-widgets.org"
+"Source" = "https://github.com/Fabric-Development/fabric"
+"Bug Tracker" = "https://github.com/Fabric-Development/fabric/issues"
+"Changelog" = "https://github.com/Fabric-Development/fabric/blob/master/CHANGELOG.md"
diff --git a/fabric/requirements.txt b/fabric/requirements.txt
new file mode 100644
index 0000000..8bb9689
--- /dev/null
+++ b/fabric/requirements.txt
@@ -0,0 +1,6 @@
+click
+loguru
+psutil
+pycairo
+PyGObject==3.50.0
+pygobject-stubs
diff --git a/fabric/ruff.toml b/fabric/ruff.toml
new file mode 100644
index 0000000..a0cefdd
--- /dev/null
+++ b/fabric/ruff.toml
@@ -0,0 +1,10 @@
+line-length = 88
+indent-width = 4
+
+[lint]
+# ignore gi imports not begin at the top of the file
+ignore = ["E402"]
+
+[format]
+quote-style = "double"
+indent-style = "space"
diff --git a/fabric/run-widget.nix b/fabric/run-widget.nix
new file mode 100644
index 0000000..ebcbdfc
--- /dev/null
+++ b/fabric/run-widget.nix
@@ -0,0 +1,58 @@
+{
+ python3,
+ stdenv,
+ gtk3,
+ gtk-layer-shell,
+ cairo,
+ gobject-introspection,
+ libdbusmenu-gtk3,
+ gdk-pixbuf,
+ gnome-bluetooth,
+ cinnamon-desktop,
+ librsvg,
+ extraPythonPackages ? [],
+ extraBuildInputs ? [],
+}: let
+ python = python3.withPackages (
+ ps:
+ with ps;
+ [
+ click
+ pycairo
+ pygobject3
+ loguru
+ psutil
+ python-fabric
+ pygobject-stubs
+ ]
+ ++ extraPythonPackages
+ );
+in
+ stdenv.mkDerivation {
+ name = "run-widget";
+ propagatedBuildInputs =
+ [
+ gtk3
+ gtk-layer-shell
+ cairo
+ gobject-introspection
+ libdbusmenu-gtk3
+ gdk-pixbuf
+ librsvg
+ gnome-bluetooth
+ cinnamon-desktop
+ ]
+ ++ extraBuildInputs;
+ phases = ["installPhase"];
+ installPhase = ''
+ mkdir -p $out/bin
+ cat > $out/bin/run-widget << EOF
+ #!/bin/sh
+ GI_TYPELIB_PATH=$GI_TYPELIB_PATH \
+ GDK_PIXBUF_MODULE_FILE="$GDK_PIXBUF_MODULE_FILE" \
+ ${python.interpreter} "\$@"
+ EOF
+ chmod +x $out/bin/run-widget
+ '';
+ meta.mainProgram = "run-widget";
+ }
diff --git a/fabric/scripts/install_libcvc/install_libcvc.sh b/fabric/scripts/install_libcvc/install_libcvc.sh
new file mode 100755
index 0000000..fedcfee
--- /dev/null
+++ b/fabric/scripts/install_libcvc/install_libcvc.sh
@@ -0,0 +1,77 @@
+# !/usr/bin/env bash
+
+cinnamon_git="https://github.com/linuxmint/cinnamon-desktop"
+cinnamon_git_name="cinnamon-desktop"
+cwd="$(pwd)"
+
+pre_check_exists() {
+ # check if Cvc typelibs are installed
+ echo "checking if libcvc is already installed..."
+ if ls /usr/lib/girepository-1.0/Cvc-1.0.typelib 1> /dev/null 2>&1;
+ then
+ echo "typelib files are already installed, reinstall? [y/n] (default: n)";
+ read answer
+ if [ "$answer" != "${answer#[Yy]}" ]; then
+ echo "do you want to remove existing files? [y/n] (default: n)"
+ read answer2
+ if [ "$answer2" != "${answer2#[Yy]}" ]; then
+ sudo rm -rf /usr/lib/girepository-1.0/Cvc-1.0.typelib
+ sudo rm -rf /usr/share/gir-1.0/Cvc-1.0.gir
+ sudo rm -rf /usr/lib/libcvc.s*
+ fi
+ else
+ exit 0
+ fi
+ else
+ echo "typelib files not found, check meson logs";
+ fi
+}
+
+prepare() {
+ pre_check_exists
+ mkdir -p ignore_me_libcvc_build
+ cd ignore_me_libcvc_build
+ git clone ${cinnamon_git}
+ patch -i ../$(dirname $0)/meson.build.patch ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name}/meson.build
+ # ^ we shorten the build time by removing unused build targets
+}
+
+build() {
+ # patch and build
+ cd ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name}
+ meson build
+ cd build
+ ninja
+}
+
+check_gir() {
+ # check if gir files got generated
+ cd ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name}/build
+ if ls libcvc/*.gir 1> /dev/null 2>&1;
+ then echo "gir files got generated, proceeding to install";
+ else
+ echo "gir files not found, check meson logs";
+ exit 1
+ fi
+}
+
+install_gir() {
+ # install gir files
+ cd ${cwd}/ignore_me_libcvc_build/${cinnamon_git_name}/build
+ meson install
+ cd libcvc
+ sudo cp -r libcvc.s* /usr/lib/
+ sudo cp Cvc-1.0.typelib /usr/lib/girepository-1.0/
+ sudo cp Cvc-1.0.gir /usr/share/gir-1.0/
+}
+
+clean_exit() {
+ rm -rf ${cwd}/ignore_me_libcvc_build
+ exit 0
+}
+
+prepare
+build
+check_gir
+install_gir
+clean_exit
diff --git a/fabric/scripts/install_libcvc/meson.build.patch b/fabric/scripts/install_libcvc/meson.build.patch
new file mode 100644
index 0000000..d08b0a8
--- /dev/null
+++ b/fabric/scripts/install_libcvc/meson.build.patch
@@ -0,0 +1,16 @@
+diff --git a/meson.build b/meson.build
+index f0bfceb..e089432 100644
+--- a/meson.build
++++ b/meson.build
+@@ -110,11 +110,7 @@ configure_file(
+ configuration: conf
+ )
+
+-subdir('install-scripts')
+-subdir('po')
+-subdir('libcinnamon-desktop')
+ subdir('libcvc')
+-subdir('schemas')
+
+
+ pnp_message = '@0@: @1@'.format(
diff --git a/fabric/setup.py b/fabric/setup.py
new file mode 100644
index 0000000..c68abca
--- /dev/null
+++ b/fabric/setup.py
@@ -0,0 +1,41 @@
+from setuptools import setup, find_packages
+
+setup(
+ name="fabric",
+ version="0.0.2",
+ description="Next-Gen python framework for creating system widgets on *Nix systems!",
+ long_description=open("README.md").read(),
+ keywords=[
+ "widgets",
+ "gtk",
+ "linux",
+ "pygobject",
+ "bar",
+ "statusbar",
+ "panel",
+ "dock",
+ ],
+ classifiers=["Programming Language :: Python"],
+ url="https://github.com/Fabric-Development/fabric.git",
+ project_urls={
+ "Website": "https://ffpy.org",
+ "Documentation": "https://wiki.ffpy.org",
+ "Website (mirror)": "https://fabric-widgets.org",
+ "Documentation (mirror)": "https://wiki.fabric-widgets.org",
+ "Source": "https://github.com/Fabric-Development/fabric",
+ "Bug Tracker": "https://github.com/Fabric-Development/fabric/issues",
+ "Changelog": "https://github.com/Fabric-Development/fabric/blob/master/CHANGELOG.md",
+ },
+ packages=find_packages(),
+ install_requires=[
+ "click",
+ "loguru",
+ "pycairo",
+ "PyGObject==3.50.0",
+ ],
+ python_requires=">=3.11",
+ extras_require={
+ "system-status": ["psutil"],
+ },
+ package_data={"*": ["*.xml", "*.js"]},
+)
diff --git a/fabric/tests/service.1.py b/fabric/tests/service.1.py
new file mode 100644
index 0000000..5dd0338
--- /dev/null
+++ b/fabric/tests/service.1.py
@@ -0,0 +1,22 @@
+import unittest
+from fabric.core import Service
+
+
+class TestServiceInit(unittest.TestCase):
+ def testInitialize(self):
+ service = Service()
+
+ def testPropertiesCount(self):
+ service = Service()
+ message = "length of properties should be zero for pure Service objects"
+ self.assertEqual(len(service.get_properties()), 0, message)
+
+ def testSignalsCount(self):
+ service = Service()
+ message = "length of Signals should be zero for pure Service objects"
+ self.assertEqual(len(service.get_signal_names()), 0, message)
+ self.assertEqual(len(service.get_signal_ids()), 0, message)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/fabric/tests/service.2.py b/fabric/tests/service.2.py
new file mode 100644
index 0000000..e154372
--- /dev/null
+++ b/fabric/tests/service.2.py
@@ -0,0 +1,201 @@
+import unittest
+from typing import Any
+from fabric.core import Service, Property
+
+
+class BaseService(Service):
+ int_prop = Property(int)
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.int_prop = 42
+
+
+class TestServiceProperties(unittest.TestCase):
+ class InheritedService(BaseService):
+ str_prop = Property(str)
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.str_prop = "42"
+
+ class ServiceWithProperties(Service):
+ int_prop = Property(int)
+ str_prop = Property(str)
+ bool_prop = Property(bool, default_value=False)
+ boxed_prop = Property(object)
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.int_prop = 42
+ self.str_prop = "42"
+ self.bool_prop = True
+ self.boxed_prop = object
+ self._decor_prop: str = "decor-prop"
+
+ @Property(str)
+ def decor_prop(self):
+ return self._decor_prop
+
+ @decor_prop.setter
+ def decor_prop(self, value):
+ self._decor_prop = value
+ return
+
+ def testOrphaned(self):
+ int_prop = Property(int)
+ str_prop = Property(str)
+ bool_prop = Property(bool, default_value=False)
+ boxed_prop = Property(object)
+
+ int_prop = Any
+ str_prop = Any
+ bool_prop = Any
+ boxed_prop = Any
+
+ for prop in [int_prop, str_prop, bool_prop, boxed_prop]:
+ self.assertEqual(prop, Any)
+
+ def testOrphanedDifferentTypes(self):
+ int_prop = Property(int)
+ str_prop = Property(str)
+ bool_prop = Property(bool, default_value=False)
+ boxed_prop = Property(object)
+
+ int_prop = 42
+ str_prop = "42"
+ bool_prop = True
+ boxed_prop = object
+
+ self.assertEqual(int_prop, 42)
+ self.assertEqual(str_prop, "42")
+ self.assertEqual(bool_prop, True)
+ self.assertEqual(boxed_prop, object)
+
+ def testServicePropertiesSimple(self):
+ service = self.ServiceWithProperties()
+ self.assertEqual(service.int_prop, 42)
+ self.assertEqual(service.str_prop, "42")
+ self.assertEqual(service.bool_prop, True)
+ self.assertEqual(service.boxed_prop, object)
+
+ def testServicePropertiesComplex(self):
+ service = self.ServiceWithProperties()
+ self.assertEqual(service.int_prop, 42)
+ self.assertEqual(service.str_prop, "42")
+ self.assertEqual(service.bool_prop, True)
+ self.assertEqual(service.boxed_prop, object)
+
+ service["int-prop"] = 4002
+ self.assertEqual(service.int_prop, 4002)
+ service["str-prop"] = "4002"
+ self.assertEqual(service.str_prop, "4002")
+ service["bool-prop"] = False
+ self.assertEqual(service.bool_prop, False)
+ service["boxed-prop"] = None
+ self.assertEqual(service.boxed_prop, None)
+
+ self.assertEqual(service["int-prop"], 4002)
+ self.assertEqual(service["str-prop"], "4002")
+ self.assertEqual(service["bool-prop"], False)
+ self.assertEqual(service["boxed-prop"], None)
+
+ def testPropertyNotify(self):
+ notifiedSignals: list[str] = []
+ expectedOutput = [
+ "int-prop",
+ "str-prop",
+ "bool-prop",
+ "boxed-prop",
+ "decor-prop",
+ ]
+
+ def onNotify(propertyName: str):
+ notifiedSignals.append(propertyName)
+
+ service = self.ServiceWithProperties(
+ notify_int_prop=lambda *args: onNotify("int-prop"),
+ notify_str_prop=lambda *args: onNotify("str-prop"),
+ notify_bool_prop=lambda *args: onNotify("bool-prop"),
+ notify_boxed_prop=lambda *args: onNotify("boxed-prop"),
+ notify_decor_prop=lambda *args: onNotify("decor-prop"),
+ )
+ service.notify("decor-prop") # decorated properties doesn't get initialized
+ self.assertEqual(notifiedSignals, expectedOutput)
+ notifiedSignals.clear()
+
+ service["int-prop"] = 4002
+ service["str-prop"] = "4002"
+ service["bool-prop"] = False
+ service["boxed-prop"] = None
+ service["decor-prop"] = "decor"
+ self.assertEqual(notifiedSignals, expectedOutput)
+
+ def testPropertyNotifyAndCheckValue(self):
+ notifiedSignals: dict[str, Any] = {}
+ expectedOutput = {
+ "int-prop": 42,
+ "str-prop": "42",
+ "bool-prop": True,
+ "boxed-prop": object,
+ "decor-prop": "decor-prop",
+ }
+ expectedModifiedOutput = {
+ "int-prop": 4002,
+ "str-prop": "4002",
+ "bool-prop": False,
+ "boxed-prop": None,
+ "decor-prop": "decor",
+ }
+
+ def onNotify(self: Service, propertyName: str):
+ notifiedSignals[propertyName] = self[propertyName]
+
+ service = self.ServiceWithProperties(
+ notify_int_prop=lambda self, *args: onNotify(self, "int-prop"),
+ notify_str_prop=lambda self, *args: onNotify(self, "str-prop"),
+ notify_bool_prop=lambda self, *args: onNotify(self, "bool-prop"),
+ notify_boxed_prop=lambda self, *args: onNotify(self, "boxed-prop"),
+ notify_decor_prop=lambda self, *args: onNotify(self, "decor-prop"),
+ )
+ service.notify("decor-prop")
+ self.assertEqual(notifiedSignals, expectedOutput)
+ notifiedSignals.clear()
+
+ service["int-prop"] = 4002
+ service["str-prop"] = "4002"
+ service["bool-prop"] = False
+ service["boxed-prop"] = None
+ service["decor-prop"] = "decor"
+ self.assertEqual(notifiedSignals, expectedModifiedOutput)
+ notifiedSignals.clear()
+ del service
+
+ service = self.ServiceWithProperties()
+
+ for propertyName, _ in expectedOutput.items():
+ service.connect(
+ f"notify::{propertyName}",
+ lambda self, *args, pName=propertyName: onNotify(self, pName),
+ )
+
+ service["int-prop"] = 4002
+ service["str-prop"] = "4002"
+ service["bool-prop"] = False
+ service["boxed-prop"] = None
+ service["decor-prop"] = "decor"
+ self.assertEqual(notifiedSignals, expectedModifiedOutput)
+
+ def testInheritedProperties(self):
+ service = self.InheritedService()
+ self.assertEqual(service.int_prop, 42)
+ self.assertEqual(service.str_prop, "42")
+
+ service.int_prop = 4002
+ service.str_prop = "4002"
+ self.assertEqual(service.int_prop, 4002)
+ self.assertEqual(service.str_prop, "4002")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/fabric/tests/service.3.py b/fabric/tests/service.3.py
new file mode 100644
index 0000000..d18d010
--- /dev/null
+++ b/fabric/tests/service.3.py
@@ -0,0 +1,311 @@
+import unittest
+from fabric.core import Service, Signal
+
+
+class TestServiceSignals(unittest.TestCase):
+ class ServiceWithSignals(Service):
+ int_signal = Signal("int-signal", "run-first", None, (int,))
+ str_signal = Signal("str-signal", "run-first", None, (str,))
+ bool_signal = Signal("bool-signal", "run-first", None, (bool,))
+ boxed_signal = Signal("boxed-signal", "run-first", None, (object,))
+
+ @Signal
+ def complex_signal(
+ self, intValue: int, strValue: str, boolValue: bool, boxedObject: object
+ ):
+ return "signalCalled"
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def testOrphaned(self):
+ int_signal = Signal("int-signal", "run-first", None, (int,))
+ str_signal = Signal("str-signal", "run-first", None, (str,))
+ bool_signal = Signal("bool-signal", "run-first", None, (bool,))
+ boxed_signal = Signal("boxed-signal", "run-first", None, (object,))
+
+ @Signal
+ def complex_signal(
+ self, strValue: str, intValue: int, boolValue: bool, boxedObject: object
+ ): ...
+
+ def testServiceSignalsConnect1(self):
+ service = self.ServiceWithSignals()
+ service.connect("int-signal", lambda *args: ...)
+ service.connect("str-signal", lambda *args: ...)
+ service.connect("bool-signal", lambda *args: ...)
+ service.connect("boxed-signal", lambda *args: ...)
+ service.connect("complex-signal", lambda *args: ...)
+
+ def testServiceSignalsConnect2(self):
+ self.ServiceWithSignals(
+ on_int_signal=lambda *args: ...,
+ on_str_signal=lambda *args: ...,
+ on_bool_signal=lambda *args: ...,
+ on_boxed_signal=lambda *args: ...,
+ on_complex_signal=lambda *args: ...,
+ )
+
+ def testServiceSignalsConnect3(self):
+ service = self.ServiceWithSignals()
+ service.int_signal.connect(lambda *args: ...)
+ service.str_signal.connect(lambda *args: ...)
+ service.bool_signal.connect(lambda *args: ...)
+ service.boxed_signal.connect(lambda *args: ...)
+ service.complex_signal.connect(lambda *args: ...)
+
+ def testServiceSignalsEmit1(self):
+ service = self.ServiceWithSignals()
+ service.emit("int-signal", 42)
+ service.emit("str-signal", "42")
+ service.emit("bool-signal", True)
+ service.emit("boxed-signal", object)
+ service.emit("complex-signal", 42, "42", True, object)
+
+ def testServiceSignalsEmit2(self):
+ service = self.ServiceWithSignals()
+ service.int_signal.emit(42)
+ service.str_signal.emit("42")
+ service.bool_signal.emit(True)
+ service.boxed_signal.emit(object)
+ service.complex_signal.emit(42, "42", True, object)
+
+ def testServiceSignalsEmit3(self):
+ service = self.ServiceWithSignals()
+ service.int_signal(42)
+ service.str_signal("42")
+ service.bool_signal(True)
+ service.boxed_signal(object)
+ service.complex_signal(42, "42", True, object)
+
+ def testServiceSignalsConnectAndEmit1(self):
+ emittedSignals: list[str] = []
+ expectedOutput = [
+ "int-signal",
+ "str-signal",
+ "bool-signal",
+ "boxed-signal",
+ "complex-signal",
+ ]
+
+ def onSignal(signalName):
+ emittedSignals.append(signalName)
+
+ service = self.ServiceWithSignals()
+ service.connect("int-signal", lambda *args: onSignal("int-signal"))
+ service.connect("str-signal", lambda *args: onSignal("str-signal"))
+ service.connect("bool-signal", lambda *args: onSignal("bool-signal"))
+ service.connect("boxed-signal", lambda *args: onSignal("boxed-signal"))
+ service.connect("complex-signal", lambda *args: onSignal("complex-signal"))
+
+ service.emit("int-signal", 42)
+ service.emit("str-signal", "42")
+ service.emit("bool-signal", True)
+ service.emit("boxed-signal", object)
+ service.emit("complex-signal", 42, "42", True, object)
+
+ self.assertEqual(emittedSignals, expectedOutput)
+ emittedSignals = []
+
+ def testServiceSignalsConnectAndEmit2(self):
+ emittedSignals: list[str] = []
+ expectedOutput = [
+ "int-signal",
+ "str-signal",
+ "bool-signal",
+ "boxed-signal",
+ "complex-signal",
+ ]
+
+ def onSignal(signalName):
+ emittedSignals.append(signalName)
+
+ service = self.ServiceWithSignals()
+ service.int_signal.connect(lambda *args: onSignal("int-signal"))
+ service.str_signal.connect(lambda *args: onSignal("str-signal"))
+ service.bool_signal.connect(lambda *args: onSignal("bool-signal"))
+ service.boxed_signal.connect(lambda *args: onSignal("boxed-signal"))
+ service.complex_signal.connect(lambda *args: onSignal("complex-signal"))
+
+ service.int_signal(42)
+ service.str_signal("42")
+ service.bool_signal(True)
+ service.boxed_signal(object)
+ service.complex_signal(42, "42", True, object)
+
+ self.assertEqual(emittedSignals, expectedOutput)
+ emittedSignals = []
+
+ def testServiceSignalsConnectAndEmit3(self):
+ emittedSignals: list[str] = []
+ expectedOutput = [
+ "int-signal",
+ "str-signal",
+ "bool-signal",
+ "boxed-signal",
+ "complex-signal",
+ ]
+
+ def onSignal(signalName):
+ emittedSignals.append(signalName)
+
+ service = self.ServiceWithSignals()
+
+ service.int_signal.connect(lambda *args: onSignal("int-signal"))
+ service.str_signal.connect(lambda *args: onSignal("str-signal"))
+ service.bool_signal.connect(lambda *args: onSignal("bool-signal"))
+ service.boxed_signal.connect(lambda *args: onSignal("boxed-signal"))
+ service.complex_signal.connect(lambda *args: onSignal("complex-signal"))
+
+ service.int_signal.emit(42)
+ service.str_signal.emit("42")
+ service.bool_signal.emit(True)
+ service.boxed_signal.emit(object)
+ service.complex_signal.emit(42, "42", True, object)
+
+ self.assertEqual(emittedSignals, expectedOutput)
+ emittedSignals = []
+
+ def testServiceSignalsConnectAndEmit4(self):
+ emittedSignals: list[str] = []
+ expectedOutput = [
+ "int-signal",
+ "str-signal",
+ "bool-signal",
+ "boxed-signal",
+ "complex-signal",
+ ]
+
+ def onSignal(signalName):
+ emittedSignals.append(signalName)
+
+ service = self.ServiceWithSignals(
+ on_int_signal=lambda *args: onSignal("int-signal"),
+ on_str_signal=lambda *args: onSignal("str-signal"),
+ on_bool_signal=lambda *args: onSignal("bool-signal"),
+ on_boxed_signal=lambda *args: onSignal("boxed-signal"),
+ on_complex_signal=lambda *args: onSignal("complex-signal"),
+ )
+
+ service.emit("int-signal", 42)
+ service.emit("str-signal", "42")
+ service.emit("bool-signal", True)
+ service.emit("boxed-signal", object)
+ service.emit("complex-signal", 42, "42", True, object)
+
+ self.assertEqual(emittedSignals, expectedOutput)
+ emittedSignals = []
+
+ def testServiceSignalsConnectAndEmit5(self):
+ # ignorable arguments
+ emittedSignals: list[str] = []
+ expectedOutput = [
+ "int-signal",
+ "str-signal",
+ "bool-signal",
+ "boxed-signal",
+ "complex-signal",
+ ]
+
+ def onSignal(signalName):
+ emittedSignals.append(signalName)
+
+ service = self.ServiceWithSignals(
+ on_int_signal=lambda: onSignal("int-signal"),
+ on_str_signal=lambda: onSignal("str-signal"),
+ on_bool_signal=lambda: onSignal("bool-signal"),
+ on_boxed_signal=lambda: onSignal("boxed-signal"),
+ on_complex_signal=lambda: onSignal("complex-signal"),
+ )
+
+ service.emit("int-signal", 42)
+ service.emit("str-signal", "42")
+ service.emit("bool-signal", True)
+ service.emit("boxed-signal", object)
+ service.emit("complex-signal", 42, "42", True, object)
+
+ self.assertEqual(emittedSignals, expectedOutput)
+ emittedSignals = []
+
+ def testServiceSignalsConnectAndEmit6(self):
+ # ignorable arguments
+ emittedSignals: list[str] = []
+ expectedOutput = [
+ "int-signal",
+ "str-signal",
+ "bool-signal",
+ "boxed-signal",
+ "complex-signal",
+ ]
+
+ def onSignal(signalName):
+ emittedSignals.append(signalName)
+
+ service = self.ServiceWithSignals(
+ on_int_signal=lambda s: self.assertEqual(s, service)
+ or onSignal("int-signal"),
+ on_str_signal=lambda s: self.assertEqual(s, service)
+ or onSignal("str-signal"),
+ on_bool_signal=lambda s: self.assertEqual(s, service)
+ or onSignal("bool-signal"),
+ on_boxed_signal=lambda s: self.assertEqual(s, service)
+ or onSignal("boxed-signal"),
+ on_complex_signal=lambda s: self.assertEqual(s, service)
+ or onSignal("complex-signal"),
+ )
+
+ service.emit("int-signal", 42)
+ service.emit("str-signal", "42")
+ service.emit("bool-signal", True)
+ service.emit("boxed-signal", object)
+ service.emit("complex-signal", 42, "42", True, object)
+
+ self.assertEqual(emittedSignals, expectedOutput)
+ emittedSignals = []
+
+ def testServiceSignalsConnectAndEmit7(self):
+ # ignorable arguments
+ emittedSignals: list[str] = []
+ expectedOutput = [
+ "int-signal",
+ "str-signal",
+ "bool-signal",
+ "boxed-signal",
+ "complex-signal",
+ ]
+
+ def onSignal(signalName):
+ emittedSignals.append(signalName)
+
+ service = self.ServiceWithSignals(
+ on_int_signal=lambda s, i: self.assertEqual(i, 42)
+ or self.assertEqual(s, service)
+ or onSignal("int-signal"),
+ on_str_signal=lambda s, i: self.assertEqual(i, "42")
+ or self.assertEqual(s, service)
+ or onSignal("str-signal"),
+ on_bool_signal=lambda s, i: self.assertEqual(i, True)
+ or self.assertEqual(s, service)
+ or onSignal("bool-signal"),
+ on_boxed_signal=lambda s, i: self.assertEqual(i, object)
+ or self.assertEqual(s, service)
+ or onSignal("boxed-signal"),
+ on_complex_signal=lambda s, i1, i2, i3, i4: self.assertEqual(
+ (i1, i2, i3, i4), (42, "42", True, object)
+ )
+ or self.assertEqual(s, service)
+ or onSignal("complex-signal"),
+ )
+
+ service.emit("int-signal", 42)
+ service.emit("str-signal", "42")
+ service.emit("bool-signal", True)
+ service.emit("boxed-signal", object)
+ service.emit("complex-signal", 42, "42", True, object)
+
+ self.assertEqual(emittedSignals, expectedOutput)
+ emittedSignals = []
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/fabric/tests/service.4.py b/fabric/tests/service.4.py
new file mode 100644
index 0000000..1838b2f
--- /dev/null
+++ b/fabric/tests/service.4.py
@@ -0,0 +1,56 @@
+import unittest
+from fabric.core import Service, Property, Builder
+
+
+class TestService(Service):
+ @Property(str, "read-write")
+ def prop1(self):
+ return self._prop1
+
+ @prop1.setter
+ def prop1(self, value: str):
+ self._prop1 = value
+ return
+
+ @Property(str, "read-write")
+ def prop2(self):
+ return self._prop2
+
+ @prop2.setter
+ def prop2(self, value: str):
+ self._prop2 = value
+ return
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self._prop1 = ""
+ self._prop2 = ""
+
+
+class TestServiceBuilder(unittest.TestCase):
+ def testGetBuilder(self):
+ service = TestService()
+ builder = service.build()
+ self.assertEqual(type(builder), Builder)
+
+ self.assertIs(builder.set_prop1("4002").set_prop2("42").unwrap(), service)
+ self.assertEqual(service.prop1, "4002")
+ self.assertEqual(service.prop2, "42")
+
+ def testGetBuilderCallback(self):
+ service = TestService()
+ builder = None
+
+ def builder_callback(_, _builder: Builder):
+ nonlocal builder
+ builder = _builder
+ return builder.set_prop1("4002").set_prop2("42").unwrap()
+
+ self.assertIs(service.build(builder_callback), service)
+ self.assertEqual(type(builder), Builder)
+ self.assertEqual(service.prop1, "4002")
+ self.assertEqual(service.prop2, "42")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/styles/colors.css b/styles/colors.css
index 0f3590e..fc3ea8a 100644
--- a/styles/colors.css
+++ b/styles/colors.css
@@ -1,20 +1,20 @@
:vars {
- --foreground: #e3e1e9;
- --background: #121318;
- --cursor: #e3e1e9;
- --primary: #bac3ff;
- --on-primary: #222c61;
- --secondary: #c3c5dd;
- --on-secondary: #2c2f42;
- --tertiary: #e5bad8;
- --on-tertiary: #44263e;
- --surface: #121318;
- --surface-bright: #38393f;
+ --foreground: #e4e1e9;
+ --background: #131318;
+ --cursor: #e4e1e9;
+ --primary: #bfc2ff;
+ --on-primary: #272b60;
+ --secondary: #c5c4dd;
+ --on-secondary: #2e2f42;
+ --tertiary: #e8b9d4;
+ --on-tertiary: #46263b;
+ --surface: #131318;
+ --surface-bright: #39383f;
--error: #ffb4ab;
--error-dim: #ff8678;
--on-error: #690005;
--error-container: #93000a;
- --outline: #90909a;
+ --outline: #918f9a;
--shadow: #000000;
--red: #ffb2b9;
--red-dim: #ff7f8b;
@@ -22,8 +22,8 @@
--green-dim: #70c789;
--yellow: #b8cf84;
--yellow-dim: #a3c15f;
- --blue: #bcc2ff;
- --blue-dim: #8994ff;
+ --blue: #bfc2ff;
+ --blue-dim: #8c91ff;
--magenta: #e4b7f3;
--magenta-dim: #d48bec;
--cyan: #82d3e2;