From aeebf26b8d1dd8e41ce07eb2b159353db5ecb72d Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Fri, 4 Jul 2025 12:41:17 +0545 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20-=20This=20commit=20adds=20examp?= =?UTF-8?q?le=20configurations=20for=20several=20features=20and=20widgets,?= =?UTF-8?q?=20including=20app=20launcher,=20status=20bar,=20bluetooth=20wi?= =?UTF-8?q?dget,=20desktop=20widget,=20icon=20browser,=20notifications,=20?= =?UTF-8?q?and=20side=20panel.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/app-launcher/config.py | 149 ++++++++++ examples/bar/config.py | 170 +++++++++++ examples/bar/style.css | 169 +++++++++++ examples/bar/wayland.py | 359 +++++++++++++++++++++++ examples/bluetooth-widget/config.py | 112 +++++++ examples/desktop-widget/config.py | 26 ++ examples/desktop-widget/style.css | 38 +++ examples/icon-browser/config.py | 105 +++++++ examples/notifications/config.py | 162 ++++++++++ examples/notifications/style.css | 40 +++ examples/side-panel/config.py | 174 +++++++++++ examples/side-panel/style.css | 83 ++++++ modules/launcher/main.py | 1 - modules/launcher/plugins/applications.py | 20 +- 14 files changed, 1596 insertions(+), 12 deletions(-) create mode 100644 examples/app-launcher/config.py create mode 100644 examples/bar/config.py create mode 100644 examples/bar/style.css create mode 100644 examples/bar/wayland.py create mode 100644 examples/bluetooth-widget/config.py create mode 100644 examples/desktop-widget/config.py create mode 100644 examples/desktop-widget/style.css create mode 100644 examples/icon-browser/config.py create mode 100644 examples/notifications/config.py create mode 100644 examples/notifications/style.css create mode 100644 examples/side-panel/config.py create mode 100644 examples/side-panel/style.css diff --git a/examples/app-launcher/config.py b/examples/app-launcher/config.py new file mode 100644 index 0000000..dd5640e --- /dev/null +++ b/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/examples/bar/config.py b/examples/bar/config.py new file mode 100644 index 0000000..7265629 --- /dev/null +++ b/examples/bar/config.py @@ -0,0 +1,170 @@ +import psutil +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.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 wayland import WaylandWindow as Window +from fabric.hyprland.widgets import Language, ActiveWindow, Workspaces, WorkspaceButton +from fabric.utils import ( + FormattedString, + bulk_replace, + invoke_repeater, + get_relative_path, +) + +AUDIO_WIDGET = True + +if AUDIO_WIDGET is True: + try: + from fabric.audio.service import Audio + except Exception as e: + print(e) + AUDIO_WIDGET = False + + +class VolumeWidget(Box): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.audio = Audio() + + self.progress_bar = CircularProgressBar( + name="volume-progress-bar", pie=True, size=24 + ) + + self.event_box = EventBox( + events="scroll", + child=Overlay( + child=self.progress_bar, + overlays=Label( + label="", + style="margin: 0px 6px 0px 0px; font-size: 12px", # to center the icon glyph + ), + ), + ) + + self.audio.connect("notify::speaker", self.on_speaker_changed) + self.event_box.connect("scroll-event", self.on_scroll) + self.add(self.event_box) + + 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 + self.audio.speaker.bind( + "volume", "value", self.progress_bar, lambda _, v: v / 100 + ) + return + + +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, + all_visible=False, + ) + self.workspaces = Workspaces( + name="workspaces", + spacing=4, + buttons_factory=lambda ws_id: WorkspaceButton(id=ws_id, label=None), + ) + self.active_window = ActiveWindow(name="hyprland-window") + self.language = Language( + formatter=FormattedString( + "{replace_lang(language)}", + replace_lang=lambda lang: bulk_replace( + lang, + (r".*Eng.*", r".*Ar.*"), + ("ENG", "ARA"), + regex=True, + ), + ), + name="hyprland-window", + ) + self.date_time = DateTime(name="date-time") + self.system_tray = SystemTray(name="system-tray", spacing=4) + + self.ram_progress_bar = CircularProgressBar( + name="ram-progress-bar", pie=True, size=24 + ) + self.cpu_progress_bar = CircularProgressBar( + name="cpu-progress-bar", pie=True, size=24 + ) + self.progress_bars_overlay = Overlay( + child=self.ram_progress_bar, + overlays=[ + self.cpu_progress_bar, + Label("", style="margin: 0px 6px 0px 0px; font-size: 12px"), + ], + ) + + self.status_container = Box( + name="widgets-container", + spacing=4, + orientation="h", + children=self.progress_bars_overlay, + ) + self.status_container.add(VolumeWidget()) if AUDIO_WIDGET is True else None + + self.children = CenterBox( + name="bar-inner", + start_children=Box( + name="start-container", + spacing=4, + orientation="h", + children=self.workspaces, + ), + center_children=Box( + name="center-container", + spacing=4, + orientation="h", + children=self.active_window, + ), + end_children=Box( + name="end-container", + spacing=4, + orientation="h", + children=[ + self.status_container, + self.system_tray, + self.date_time, + self.language, + ], + ), + ) + + invoke_repeater(1000, self.update_progress_bars) + + self.show_all() + + def update_progress_bars(self): + self.ram_progress_bar.value = psutil.virtual_memory().percent / 100 + self.cpu_progress_bar.value = psutil.cpu_percent() / 100 + return True + + +if __name__ == "__main__": + bar = StatusBar() + app = Application("bar", bar) + app.set_stylesheet_from_file(get_relative_path("./style.css")) + + app.run() diff --git a/examples/bar/style.css b/examples/bar/style.css new file mode 100644 index 0000000..695a1fa --- /dev/null +++ b/examples/bar/style.css @@ -0,0 +1,169 @@ +/* we can use webcss variables, fabric compiles that to gtk css. + global variables can be stored in :vars */ +: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; + color: var(--foreground); + font-size: 16px; + font-family: "Jost*", sans-serif; + border-radius: 100px; +} + +button { + background-size: 400% 400%; +} + +#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); +} + + +#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); +} + + +#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); +} + + +#widgets-container { + background-color: var(--module-bg); + padding: 2px; +} + +tooltip { + border: solid 2px; + border-color: var(--border-color); + background-color: var(--window-bg); +} + +tooltip>* { + padding: 2px 4px +} diff --git a/examples/bar/wayland.py b/examples/bar/wayland.py new file mode 100644 index 0000000..0dcbe51 --- /dev/null +++ b/examples/bar/wayland.py @@ -0,0 +1,359 @@ +from gi.repository import Gdk, GObject, Gtk +import re +from collections.abc import Iterable +from enum import Enum +from typing import Literal, cast + +import cairo +import gi +from fabric.core.service import Property +from fabric.utils.helpers import extract_css_values, get_enum_member +from fabric.widgets.window import Window +from loguru import logger + +gi.require_version("Gtk", "3.0") + +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 Layer(GObject.GEnum): + BACKGROUND = 0 + BOTTOM = 1 + TOP = 2 + OVERLAY = 3 + ENTRY_NUMBER = 4 + + +class KeyboardMode(GObject.GEnum): + NONE = 0 + EXCLUSIVE = 1 + ON_DEMAND = 2 + ENTRY_NUMBER = 3 + + +class Edge(GObject.GEnum): + LEFT = 0 + RIGHT = 1 + TOP = 2 + BOTTOM = 3 + ENTRY_NUMBER = 4 + + +class WaylandWindow(Window): + @Property( + Layer, + flags="read-write", + default_value=Layer.TOP, + ) + def layer(self) -> Layer: # type: ignore + return self._layer # type: ignore + + @layer.setter + def layer( + self, + value: Literal["background", "bottom", "top", "overlay"] | Layer, + ) -> None: + self._layer = get_enum_member(Layer, value, default=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(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 + region = cairo.Region() if pass_through is True else None + self.input_shape_combine_region(region) + del region + return + + @Property( + KeyboardMode, + "read-write", + default_value=KeyboardMode.NONE, + ) + def keyboard_mode(self) -> KeyboardMode: + return self._keyboard_mode + + @keyboard_mode.setter + def keyboard_mode( + self, + value: Literal[ + "none", + "exclusive", + "on-demand", + "entry-number", + ] + | KeyboardMode, + ): + self._keyboard_mode = get_enum_member( + KeyboardMode, value, default=KeyboardMode.NONE + ) + return GtkLayerShell.set_keyboard_mode(self, self._keyboard_mode) + + @Property(tuple[Edge, ...], "read-write") + def anchor(self): + return tuple( + x + for x in [ + Edge.TOP, + Edge.RIGHT, + Edge.BOTTOM, + Edge.LEFT, + ] + if GtkLayerShell.get_anchor(self, x) + ) + + @anchor.setter + def anchor(self, value: str | Iterable[Edge]) -> None: + self._anchor = value + if isinstance(value, (list, tuple)) and all( + isinstance(edge, Edge) for edge in value + ): + for edge in [ + Edge.TOP, + Edge.RIGHT, + Edge.BOTTOM, + 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 [ + Edge.TOP, + Edge.RIGHT, + Edge.BOTTOM, + 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 = KeyboardMode.EXCLUSIVE + return kb_mode + + @keyboard_mode.setter + def keyboard_mode( + self, + value: Literal["none", "exclusive", "on-demand"] | KeyboardMode, + ): + return GtkLayerShell.set_keyboard_mode( + self, + get_enum_member( + KeyboardMode, + value, + default=KeyboardMode.NONE, + ), + ) + + def __init__( + self, + layer: Literal["background", "bottom", "top", "overlay"] | Layer = 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"] + | KeyboardMode = 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=title, + type=type, + child=child, + name=name, + visible=False, + all_visible=False, + style=style, + style_classes=style_classes, + tooltip_text=tooltip_text, + tooltip_markup=tooltip_markup, + h_align=h_align, + v_align=v_align, + h_expand=h_expand, + v_expand=v_expand, + size=size, + **kwargs, + ) + self._layer = Layer.ENTRY_NUMBER + self._keyboard_mode = KeyboardMode.NONE + self._anchor = anchor + self._exclusivity = WaylandWindowExclusivity.NONE + self._pass_through = pass_through + + 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.pass_through = pass_through + 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) + + # custom overrides + def show(self) -> None: + super().show() + return self.do_handle_post_show_request() + + def show_all(self) -> None: + super().show_all() + return self.do_handle_post_show_request() + + def do_handle_post_show_request(self) -> None: + if not self.get_children(): + logger.warning( + "[WaylandWindow] showing an empty window is not recommended, some compositors might freak out." + ) + self.pass_through = self._pass_through + return + + @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["Edge", bool]: + anchor_values = WaylandWindow.extract_anchor_values(string.lower()) + return { + Edge.TOP: "top" in anchor_values, + Edge.RIGHT: "right" in anchor_values, + Edge.BOTTOM: "bottom" in anchor_values, + Edge.LEFT: "left" in anchor_values, + } + + @staticmethod + def extract_margin(input: str | Iterable[int]) -> dict["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 { + Edge.TOP: margins[0], + Edge.RIGHT: margins[1], + Edge.BOTTOM: margins[2], + Edge.LEFT: margins[3], + } diff --git a/examples/bluetooth-widget/config.py b/examples/bluetooth-widget/config.py new file mode 100644 index 0000000..cbd3fc5 --- /dev/null +++ b/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/examples/desktop-widget/config.py b/examples/desktop-widget/config.py new file mode 100644 index 0000000..ac68b7f --- /dev/null +++ b/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/examples/desktop-widget/style.css b/examples/desktop-widget/style.css new file mode 100644 index 0000000..936c1e4 --- /dev/null +++ b/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/examples/icon-browser/config.py b/examples/icon-browser/config.py new file mode 100644 index 0000000..482eb97 --- /dev/null +++ b/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/examples/notifications/config.py b/examples/notifications/config.py new file mode 100644 index 0000000..de4995a --- /dev/null +++ b/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/examples/notifications/style.css b/examples/notifications/style.css new file mode 100644 index 0000000..2954097 --- /dev/null +++ b/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/examples/side-panel/config.py b/examples/side-panel/config.py new file mode 100644 index 0000000..b15d577 --- /dev/null +++ b/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/examples/side-panel/style.css b/examples/side-panel/style.css new file mode 100644 index 0000000..dd46df6 --- /dev/null +++ b/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/modules/launcher/main.py b/modules/launcher/main.py index 64194d2..ebb5de2 100644 --- a/modules/launcher/main.py +++ b/modules/launcher/main.py @@ -1266,6 +1266,5 @@ class Launcher(Window): if not is_trigger_suggestion and not keep_launcher_open: self.close_launcher() # For trigger suggestions and keep_launcher_open actions, the launcher stays open - except Exception as e: print(f"Error activating result: {e}") diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py index 57deb52..3f47137 100644 --- a/modules/launcher/plugins/applications.py +++ b/modules/launcher/plugins/applications.py @@ -15,22 +15,13 @@ class ApplicationsPlugin(PluginBase): super().__init__() self.display_name = "Applications" self.description = "Search and launch desktop applications" - self.applications = [] def initialize(self): # Set up triggers for applications - both with and without spaces self.set_triggers(["app"]) - self._load_applications() def cleanup(self): - self.applications = [] - - def _load_applications(self): - try: - self.applications = get_desktop_applications(include_hidden=False) - except Exception as e: - print(f"Failed to load applications: {e}") - self.applications = [] + pass def _pin_application(self, app): """Pin an application to the dock.""" @@ -82,10 +73,17 @@ class ApplicationsPlugin(PluginBase): if not query_string.strip(): return [] + # Get fresh applications list each time (like examples/app-launcher) + try: + applications = get_desktop_applications(include_hidden=False) + except Exception as e: + print(f"Failed to load applications: {e}") + applications = [] + query = query_string.lower().strip() results = [] - for app in self.applications: + for app in applications: relevance = self._calculate_relevance(app, query) if relevance > 0: # Truncate description to prevent overflow beyond 550px window