diff --git a/modules/bar/bar.py b/modules/bar/bar.py index 6a54a56..46a9d5f 100644 --- a/modules/bar/bar.py +++ b/modules/bar/bar.py @@ -7,7 +7,7 @@ from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.datetime import DateTime from fabric.widgets.label import Label -from fabric.widgets.wayland import WaylandWindow as Window +from utils.wayland import WaylandWindow as Window from modules.bar.components import Metrics, workspace, SystemIndicators, UpdatesWidget import config.data as data from gi.repository import Gdk, GLib diff --git a/modules/dock.py b/modules/dock.py index fb89eb7..0821fbf 100644 --- a/modules/dock.py +++ b/modules/dock.py @@ -7,7 +7,7 @@ from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.image import Image from fabric.widgets.eventbox import EventBox -from fabric.widgets.wayland import WaylandWindow as Window +from utils.wayland import WaylandWindow as Window from fabric.hyprland.widgets import get_hyprland_connection from fabric.utils import ( exec_shell_command, diff --git a/modules/launcher/launcher.py b/modules/launcher/launcher.py index 4704e75..ed83c49 100644 --- a/modules/launcher/launcher.py +++ b/modules/launcher/launcher.py @@ -1,6 +1,6 @@ from fabric.widgets.centerbox import CenterBox from fabric.widgets.stack import Stack -from fabric.widgets.wayland import WaylandWindow as Window +from utils.wayland import WaylandWindow as Window from fabric.widgets.box import Box from modules.launcher.components import ( AppLauncher, diff --git a/modules/notification_popup.py b/modules/notification_popup.py index 82dcb5d..6d5bd7c 100644 --- a/modules/notification_popup.py +++ b/modules/notification_popup.py @@ -12,7 +12,7 @@ from fabric.widgets.image import Image from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer -from fabric.widgets.wayland import WaylandWindow as Window +from utils.wayland import WaylandWindow as Window from utils import CustomImage import utils.icons as icons from services import notification_service @@ -291,7 +291,7 @@ class NotificationPopup(Window): notification: Notification = fabric_notif.get_notification_from_id(id) # Always cache the notification regardless of DND status self.cache_notification_service.cache_notification(notification) - + # Only show popup if DND is not enabled if not self.cache_notification_service.dont_disturb: new_box = NotificationRevealer(fabric_notif.get_notification_from_id(id)) @@ -299,4 +299,8 @@ class NotificationPopup(Window): new_box.set_reveal_child(True) logger.info(f"[Notification] New notification from {notification.app_name}") else: - logger.info(f"[Notification] DND enabled, notification from {notification.app_name} not shown") + logger.info( + f"[Notification] DND enabled, notification from { + notification.app_name + } not shown" + ) diff --git a/modules/osd.py b/modules/osd.py index 6d79a60..4f4521f 100644 --- a/modules/osd.py +++ b/modules/osd.py @@ -4,7 +4,7 @@ from typing import ClassVar from fabric.audio import Audio from fabric.widgets.box import Box from fabric.widgets.scale import Scale, ScaleMark -from fabric.widgets.wayland import WaylandWindow as Window +from utils.wayland import WaylandWindow as Window from fabric.utils import invoke_repeater from gi.repository import GObject from services.brightness import Brightness diff --git a/utils/wayland.py b/utils/wayland.py new file mode 100644 index 0000000..472dc40 --- /dev/null +++ b/utils/wayland.py @@ -0,0 +1,359 @@ +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 gi.repository import Gdk, GObject, Gtk +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], + }