From 4852fa01e2f9c8882a9e4f62fd75d8b4ba4a91fe Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Thu, 7 Aug 2025 06:08:20 +0545 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(dock):=20overhaul?= =?UTF-8?q?=20dock=20implementation=20for=20improved=20performance=20and?= =?UTF-8?q?=20maintainability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - config/assets/dock.json | 40 +- config/assets/icons/media/next.svg | 9 - config/assets/icons/media/no-shuffle.svg | 14 - config/assets/icons/media/pause.svg | 11 - config/assets/icons/media/play.svg | 9 - config/assets/icons/media/previous.svg | 9 - config/assets/icons/media/shuffle.svg | 14 - config/assets/todos.json | 171 --- config/data.py | 9 +- modules/controlcenter/bluetooth.xml | 9 - modules/controlcenter/controlcenter.xml | 26 - modules/controlcenter/wifi.xml | 9 - modules/dock.py | 1572 +++++++++------------- modules/example_dock.py | 1010 -------------- modules/launcher/plugins/applications.py | 57 +- styles/dock.css | 104 +- utils/exml.py | 98 -- utils/functions.py | 51 + utils/icons.py | 234 ---- utils/monitors.py | 60 + widgets/popup_window.py | 114 +- 22 files changed, 876 insertions(+), 2755 deletions(-) delete mode 100644 config/assets/icons/media/next.svg delete mode 100644 config/assets/icons/media/no-shuffle.svg delete mode 100644 config/assets/icons/media/pause.svg delete mode 100644 config/assets/icons/media/play.svg delete mode 100644 config/assets/icons/media/previous.svg delete mode 100644 config/assets/icons/media/shuffle.svg delete mode 100644 config/assets/todos.json delete mode 100644 modules/controlcenter/bluetooth.xml delete mode 100644 modules/controlcenter/controlcenter.xml delete mode 100644 modules/controlcenter/wifi.xml delete mode 100644 modules/example_dock.py delete mode 100644 utils/exml.py create mode 100644 utils/functions.py delete mode 100644 utils/icons.py create mode 100644 utils/monitors.py diff --git a/.gitignore b/.gitignore index ccb1db1..1d519cc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ config/assets/config.json config/assets/accounts.json config/assets/passwords.json config/assets/bookmarks.json -config/assets/screentime.json config/hypr/modus.conf config/hypr/colors.conf fabric/** diff --git a/config/assets/dock.json b/config/assets/dock.json index 021f784..5cb7435 100644 --- a/config/assets/dock.json +++ b/config/assets/dock.json @@ -1,34 +1,6 @@ -{ - "pinned_apps": [ - { - "name": "Bulk Rename", - "display_name": "Bulk Rename", - "window_class": null, - "executable": "thunar", - "command_line": "thunar --bulk-rename %F" - }, - { - "name": "Ghostty", - "display_name": "Ghostty", - "window_class": "com.mitchellh.ghostty", - "executable": "ghostty", - "command_line": "ghostty" - }, - { - "name": "Zen Browser", - "display_name": "Zen Browser", - "window_class": "zen", - "executable": "/opt/zen-browser-bin/zen-bin", - "command_line": "/opt/zen-browser-bin/zen-bin %u" - }, - { - "name": "Visual Studio Code", - "display_name": "Visual Studio Code", - "window_class": "Code", - "executable": "/usr/bin/code", - "command_line": "/usr/bin/code %F", - "icon_name": "visual-studio-code" - } - ] -} - +[ + "zen", + "kitty", + "code", + "Vesktop" +] \ No newline at end of file diff --git a/config/assets/icons/media/next.svg b/config/assets/icons/media/next.svg deleted file mode 100644 index de6d70b..0000000 --- a/config/assets/icons/media/next.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/media/no-shuffle.svg b/config/assets/icons/media/no-shuffle.svg deleted file mode 100644 index aadbd5d..0000000 --- a/config/assets/icons/media/no-shuffle.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - diff --git a/config/assets/icons/media/pause.svg b/config/assets/icons/media/pause.svg deleted file mode 100644 index 4d02c52..0000000 --- a/config/assets/icons/media/pause.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - diff --git a/config/assets/icons/media/play.svg b/config/assets/icons/media/play.svg deleted file mode 100644 index bcd3011..0000000 --- a/config/assets/icons/media/play.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/media/previous.svg b/config/assets/icons/media/previous.svg deleted file mode 100644 index 792a245..0000000 --- a/config/assets/icons/media/previous.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - diff --git a/config/assets/icons/media/shuffle.svg b/config/assets/icons/media/shuffle.svg deleted file mode 100644 index 81cf35e..0000000 --- a/config/assets/icons/media/shuffle.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - diff --git a/config/assets/todos.json b/config/assets/todos.json deleted file mode 100644 index dd6113e..0000000 --- a/config/assets/todos.json +++ /dev/null @@ -1,171 +0,0 @@ -{ - "todos": [ - { - "id": 14, - "title": "yo", - "description": "", - "priority": "low", - "category": "", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:51:06.643095", - "completed_at": null - }, - { - "id": 16, - "title": "Medium priority task", - "description": "", - "priority": "medium", - "category": "normal", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:52:59.579365", - "completed_at": null - }, - { - "id": 17, - "title": "Low priority task", - "description": "", - "priority": "low", - "category": "later", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:52:59.583278", - "completed_at": null - }, - { - "id": 18, - "title": "๐Ÿ”ฅ URGENT: Fix critical bug", - "description": "", - "priority": "high", - "category": "work", - "due_date": null, - "completed": true, - "created": "2025-06-26T16:53:51.412081", - "completed_at": "2025-06-26T16:53:51.418575" - }, - { - "id": 19, - "title": "๐Ÿ“ Review documentation", - "description": "", - "priority": "medium", - "category": "work", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:53:51.413594", - "completed_at": null - }, - { - "id": 20, - "title": "๐Ÿงน Clean desk", - "description": "", - "priority": "low", - "category": "personal", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:53:51.415181", - "completed_at": null - }, - { - "id": 21, - "title": "Test high priority", - "description": "", - "priority": "high", - "category": "test", - "due_date": null, - "completed": true, - "created": "2025-06-26T16:55:12.629834", - "completed_at": "2025-06-26T17:00:48.989919" - }, - { - "id": 22, - "title": "Test medium priority", - "description": "", - "priority": "medium", - "category": "test", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:55:12.631496", - "completed_at": null - }, - { - "id": 23, - "title": "Test low priority", - "description": "", - "priority": "low", - "category": "test", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:55:12.632521", - "completed_at": null - }, - { - "id": 24, - "title": "High priority task", - "description": "", - "priority": "high", - "category": "test", - "due_date": null, - "completed": true, - "created": "2025-06-26T16:57:15.780304", - "completed_at": "2025-06-26T16:57:15.800393" - }, - { - "id": 25, - "title": "Medium priority task", - "description": "", - "priority": "medium", - "category": "test", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:57:15.783358", - "completed_at": null - }, - { - "id": 26, - "title": "Low priority task", - "description": "", - "priority": "low", - "category": "test", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:57:15.792488", - "completed_at": null - }, - { - "id": 27, - "title": "๐Ÿ”ฅ High priority", - "description": "", - "priority": "high", - "category": "test", - "due_date": null, - "completed": true, - "created": "2025-06-26T16:57:50.943800", - "completed_at": "2025-06-26T17:00:50.482300" - }, - { - "id": 28, - "title": "๐Ÿ“ Medium priority", - "description": "", - "priority": "medium", - "category": "test", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:57:50.952343", - "completed_at": null - }, - { - "id": 29, - "title": "๐Ÿงน Low priority", - "description": "", - "priority": "low", - "category": "test", - "due_date": null, - "completed": false, - "created": "2025-06-26T16:57:50.968381", - "completed_at": null - } - ], - "next_id": 30, - "last_modified": "2025-06-26T17:00:50.482384" -} \ No newline at end of file diff --git a/config/data.py b/config/data.py index 2ab2d4d..f1a3950 100644 --- a/config/data.py +++ b/config/data.py @@ -56,7 +56,7 @@ CURRENT_WIDTH = screen.get_width() CURRENT_HEIGHT = screen.get_height() -WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example/") +WALLPAPERS_DIR_DEFAULT = os.path.expanduser(f"~/Pictures/wallpapers/") CONFIG_FILE = get_relative_path("../config/assets/config.json") MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") @@ -91,8 +91,7 @@ if os.path.exists(CONFIG_FILE): DOCK_ENABLED = config.get("dock_enabled", True) DOCK_AUTO_HIDE = config.get("dock_auto_hide", True) DOCK_ALWAYS_OCCLUDED = config.get("dock_always_occluded", False) - DOCK_THEME = config.get("dock_theme", "Pills") - DOCK_ICON_SIZE = config.get("dock_icon_size", 28) + DOCK_ICON_SIZE = config.get("dock_icon_size", 60) WORKSPACE_NUMS = config.get("workspace_nums", False) WORKSPACE_USE_CHINESE_NUMERAL = config.get("workspace_use_chinese_numerals", False) WORKSPACE_DOTS = config.get("workspace_dots", False) @@ -101,6 +100,7 @@ if os.path.exists(CONFIG_FILE): DOCK_HIDE_SPECIAL_WORKSPACE_APPS = config.get( "dock_hide_special_workspace_apps", True ) + DOCK_PREVIEW_APPS = config.get("dock_preview_apps", False) NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s") NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) @@ -133,10 +133,11 @@ else: DOCK_AUTO_HIDE = True TERMINAL_COMMAND = "kitty -e" DOCK_THEME = "Pills" - DOCK_ICON_SIZE = 30 + DOCK_ICON_SIZE = 60 WINDOW_SWITCHER_ITEMS_PER_ROW = 13 DOCK_HIDE_SPECIAL_WORKSPACE = True DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True + DOCK_PREVIEW_APPS = False NOTIFICATION_TIMEOUT_STR = "5s" NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) diff --git a/modules/controlcenter/bluetooth.xml b/modules/controlcenter/bluetooth.xml deleted file mode 100644 index 04e1989..0000000 --- a/modules/controlcenter/bluetooth.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - self.bluetooth_man - - - - diff --git a/modules/controlcenter/controlcenter.xml b/modules/controlcenter/controlcenter.xml deleted file mode 100644 index 5cb49af..0000000 --- a/modules/controlcenter/controlcenter.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - self.wlan_widget - self.bluetooth_widget - - - self.focus_widget - - - - - - - - self.music_widget - - - diff --git a/modules/controlcenter/wifi.xml b/modules/controlcenter/wifi.xml deleted file mode 100644 index 5a98e08..0000000 --- a/modules/controlcenter/wifi.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - self.wifi_man - - - - diff --git a/modules/dock.py b/modules/dock.py index 404d25a..d7e2306 100644 --- a/modules/dock.py +++ b/modules/dock.py @@ -1,1010 +1,698 @@ import json -import logging +import os +import re +import subprocess -import cairo -from fabric.widgets.widget import Widget -from gi.repository import Gdk, GLib, Gtk - -import config.data as data -from fabric.hyprland.widgets import get_hyprland_connection -from fabric.utils import ( - exec_shell_command, - exec_shell_command_async, - get_relative_path, - idle_add, - remove_handler, -) -from fabric.utils.helpers import get_desktop_applications +from fabric.utils import get_relative_path, bulk_connect +import gi from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.eventbox import EventBox -from fabric.widgets.overlay import Overlay from fabric.widgets.image import Image -from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer -from modules.corners import MyCorner +from fabric.widgets.separator import Separator +from gi.repository import Glace, GLib, Gtk + +from fabric.hyprland.widgets import get_hyprland_connection +from fabric.utils.helpers import get_desktop_applications, exec_shell_command from utils.icon_resolver import IconResolver from utils.occlusion import check_occlusion +from utils.roam import modus_service from widgets.wayland import WaylandWindow as Window +from widgets.popup_window import PopupWindow +import config.data as data +from utils.functions import read_json_file, write_json_file -# TODO: use modus_service +gi.require_version("Glace", "0.1") -def read_config(): - """Read and return the full configuration from the JSON file, handling missing file.""" - config_path = get_relative_path("../config/assets/dock.json") - try: - with open(config_path, "r") as file: - config_data = json.load(file) - - if ( - "pinned_apps" in config_data - and config_data["pinned_apps"] - and isinstance(config_data["pinned_apps"][0], str) - ): - all_apps = get_desktop_applications() - app_map = {app.name: app for app in all_apps if app.name} - - old_pinned = config_data["pinned_apps"] - config_data["pinned_apps"] = [] - - for app_id in old_pinned: - app = app_map.get(app_id) - if app: - app_data_obj = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - config_data["pinned_apps"].append(app_data_obj) - else: - config_data["pinned_apps"].append({"name": app_id}) - - except (FileNotFoundError, json.JSONDecodeError): - config_data = {"pinned_apps": []} - return config_data +# Pinned apps file +PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json") -def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: - alloc = widget.get_allocation() - surface = cairo.ImageSurface( - cairo.Format.ARGB32, - alloc.width, - alloc.height, - ) - cr = cairo.Context(surface) - cr.set_source_rgba(255, 255, 255, 0) - cr.rectangle(0, 0, alloc.width, alloc.height) - cr.fill() - widget.draw(cr) - return surface +class AppBar(Box): + def __init__(self, parent: Window): + self.client_buttons = {} + self._parent = parent - -class Dock(Window): - _instances = [] - - def __init__(self, **kwargs): self.icon_size = data.DOCK_ICON_SIZE - self.effective_occlusion_size = 36 + self.icon_size - - # Dock is always positioned at the bottom - anchor_to_set = "bottom" - revealer_transition_type = "slide-up" - main_box_orientation_val = Gtk.Orientation.VERTICAL - main_box_h_align_val = "center" - dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL - - self.actual_dock_is_horizontal = True + self.preview_size = [200, 150] # Default preview size + orientation = ( + "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal" + ) super().__init__( - name="dock-window", - layer="top", - anchor=anchor_to_set, - margin="0px 0px 0px 0px", - exclusivity="none", - **kwargs, - ) - Dock._instances.append(self) - - self.config = read_config() - self.conn = get_hyprland_connection() - self.icon_resolver = IconResolver() - self.pinned = self.config.get("pinned_apps", []) - self.config_path = get_relative_path("../config/assets/dock.json") - self.app_map = {} - self._all_apps = get_desktop_applications() - self.app_identifiers = self._build_app_identifiers_map() - - self.hide_id = None - self._arranger_handler = None - self._drag_in_progress = False - self.always_occluded = data.DOCK_ALWAYS_OCCLUDED - self.is_mouse_over_dock_area = False - self._prevent_occlusion = False - - self.view = Box(name="viewport", spacing=4) - self.wrapper = Box( + spacing=10, name="dock", - children=[self.view], + children=[], + orientation=orientation, + ) + self.icon_resolver = IconResolver() + + self._manager = Glace.Manager() + self._preview_image = Image() + + # Connect to Glace manager signals to automatically handle running apps + self._manager.connect("client-added", self._on_glace_client_added) + self._manager.connect("client-removed", self._on_glace_client_removed) + + self.glace_client_buttons = {} + + self.pinned_items_pos = [] + self.running_items_pos = [] + + self.conn = get_hyprland_connection() + + self.menu = Gtk.Menu() + self.pinned_apps_container = Box() + self.add(self.pinned_apps_container) + + self.pinned_apps = read_json_file(PINNED_APPS_FILE) + if self.pinned_apps is None: + self.pinned_apps = [] + self._populate_pinned_apps(self.pinned_apps) + + self.add(Separator(name="dock-separator")) + + # Container for running apps + self.running_apps_container = Box(name="running-apps-container") + self.add(self.running_apps_container) + + # Setup preview popup if enabled + if data.DOCK_PREVIEW_APPS: + self._setup_preview_popup() + + if not self.pinned_apps: + placeholder = Button( + name="dock-app-button", + image=Image( + icon_name="view-app-grid-symbolic", icon_size=self.icon_size + ), + tooltip_text="Application Menu", + ) + self.pinned_apps_container.add(placeholder) + + # Connect to modus service signals for dock apps changes + if modus_service: + modus_service.connect("dock-apps-changed", self._on_dock_apps_changed) + modus_service.connect( + "current-workspace-changed", self._on_workspace_changed + ) + modus_service.connect( + "current-active-app-name-changed", self._on_active_app_changed + ) + self._update_modus_service_dock_apps() + GLib.timeout_add(1000, self._delayed_update) + + def _setup_preview_popup(self): + self.popup_revealer = Revealer( + child=Box( + children=self._preview_image, + style_classes=["window-basic", "sleek-border"], + ), + transition_type="crossfade", + transition_duration=400, ) - self.wrapper.set_orientation(dock_wrapper_orientation_val) - self.view.set_orientation(dock_wrapper_orientation_val) - - # Dock is always horizontal, so remove vertical style class - self.wrapper.remove_style_class("vertical") - - # Always use Dense theme - self.wrapper.add_style_class("dense") - - self.dock_eventbox = EventBox() - self.dock_eventbox.add(self.wrapper) - self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter) - self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave) - - # Create corners for horizontal dock at bottom - self.corner_left = Box( - name="dock-corner-left", - orientation=Gtk.Orientation.VERTICAL, - h_align="start", - children=[ - Box(v_expand=True, v_align="fill"), - MyCorner("bottom-right"), - ], + self.popup = PopupWindow( + parent=self._parent, + margin="0px 0px 80px 0px", + visible=False, + enable_boundary_checking=False, # Disable boundary checking for dock previews ) - self.corner_right = Box( - name="dock-corner-right", - orientation=Gtk.Orientation.VERTICAL, - h_align="end", - children=[ - Box(v_expand=True, v_align="fill"), - MyCorner("bottom-left"), - ], - ) - self.corner_top = Box() - self.corner_bottom = Box() + self.popup.children = self.popup_revealer - self.dock_full = Box( - name="dock-full", - orientation=Gtk.Orientation.HORIZONTAL, - h_expand=True, - h_align="fill", - children=[self.corner_left, self.dock_eventbox, self.corner_right], + self.popup_revealer.connect( + "notify::child-revealed", + lambda *_: ( + self.popup.set_visible(False) + if not self.popup_revealer.child_revealed + else None + ), ) - self.dock_revealer = Revealer( - name="dock-revealer", - transition_type=revealer_transition_type, - transition_duration=250, - child_revealed=False, - child=self.dock_full, - ) + def update_preview_image_glace(self, client, client_button: Button): + if not hasattr(self, "popup") or not self.popup: + return - self.hover_activator = EventBox() + self.popup.set_pointing_to(client_button) - self.hover_activator.set_size_request(-1, 1) - self.hover_activator.connect("enter-notify-event", self._on_hover_enter) - self.hover_activator.connect("leave-notify-event", self._on_hover_leave) + def capture_callback(pbuf, _): + self._preview_image.set_from_pixbuf( + pbuf.scale_simple(self.preview_size[0], self.preview_size[1], 2) + ) + self.popup.set_visible(True) + self.popup_revealer.reveal() - self.main_box = Box( - orientation=main_box_orientation_val, - children=[self.hover_activator, self.dock_revealer], - h_align=main_box_h_align_val, - ) - self.add(self.main_box) + try: + self._manager.capture_client( + client=client, + overlay_cursor=False, + callback=capture_callback, + user_data=None, + ) + except Exception as e: + pass - # Dense theme - hide corners - for corner in [ - self.corner_left, - self.corner_right, - self.corner_top, - self.corner_bottom, - ]: - corner.set_visible(False) + def hide_preview(self): + if hasattr(self, "popup_revealer") and self.popup_revealer: + self.popup_revealer.unreveal() - if not data.DOCK_ENABLED: - self.set_visible(False) + def handle_item_hovered(self, item, is_pinned=False): + try: + if is_pinned: + if item in self.pinned_items_pos: + index = self.pinned_items_pos.index(item) + # Add semi_hovered to previous item + if index > 0: + self.pinned_items_pos[index - 1].add_style_class("semi_hovered") + # Add semi_hovered to next item + if index < len(self.pinned_items_pos) - 1: + self.pinned_items_pos[index + 1].add_style_class("semi_hovered") + else: + if item in self.running_items_pos: + index = self.running_items_pos.index(item) + # Add semi_hovered to previous item + if index > 0: + self.running_items_pos[index - 1].add_style_class( + "semi_hovered" + ) + # Add semi_hovered to next item + if index < len(self.running_items_pos) - 1: + self.running_items_pos[index + 1].add_style_class( + "semi_hovered" + ) + except (ValueError, IndexError): + pass - if self.always_occluded: - self.dock_full.add_style_class("occluded") + def handle_item_unhovered(self, item, is_pinned=False): + try: + if is_pinned: + if item in self.pinned_items_pos: + index = self.pinned_items_pos.index(item) + # Remove semi_hovered from previous item + if index > 0: + self.pinned_items_pos[index - 1].remove_style_class( + "semi_hovered" + ) + # Remove semi_hovered from next item + if index < len(self.pinned_items_pos) - 1: + self.pinned_items_pos[index + 1].remove_style_class( + "semi_hovered" + ) + else: + if item in self.running_items_pos: + index = self.running_items_pos.index(item) + # Remove semi_hovered from previous item + if index > 0: + self.running_items_pos[index - 1].remove_style_class( + "semi_hovered" + ) + # Remove semi_hovered from next item + if index < len(self.running_items_pos) - 1: + self.running_items_pos[index + 1].remove_style_class( + "semi_hovered" + ) + except (ValueError, IndexError): + pass - self.view.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.view.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.view.connect("drag-data-get", self.on_drag_data_get) - self.view.connect("drag-data-received", self.on_drag_data_received) - self.view.connect("drag-begin", self.on_drag_begin) - self.view.connect("drag-end", self.on_drag_end) + def clear_all_hover_effects(self): + try: + # Clear semi_hovered from all pinned items + for item in self.pinned_items_pos: + item.remove_style_class("semi_hovered") - if self.conn.ready: - self.update_dock() - GLib.timeout_add(250, self.check_occlusion_state) - else: - self.conn.connect("event::ready", self.update_dock) - self.conn.connect( - "event::ready", - lambda *args: GLib.timeout_add(250, self.check_occlusion_state), + # Clear semi_hovered from all running items + for item in self.running_items_pos: + item.remove_style_class("semi_hovered") + + # Hide preview popup if it's showing + if hasattr(self, "popup_revealer") and self.popup_revealer: + self.popup_revealer.unreveal() + except Exception: + pass + + def _delayed_update(self): + self.update_dock() + return False # Don't repeat the timeout + + def _on_glace_client_added(self, _, client): + try: + client_image = Image() + + def on_button_press_event(event, client): + if event.button == 1: + try: + # Try to use Glace client methods if available + if ( + hasattr(client, "get_activated") + and hasattr(client, "get_minimized") + and hasattr(client, "get_maximized") + ): + if not client.get_activated(): + client.activate() + elif client.get_minimized(): + client.unminimize() + elif client.get_maximized(): + client.unmaximize() + else: + client.maximize() + else: + # Fallback to simple activate + client.activate() + except Exception: + # Final fallback to simple activate + try: + client.activate() + except Exception: + pass + elif event.button == 2: + # Middle click: Pin the app to dock + app_id = client.get_app_id() + if app_id and not self.check_if_pinned(app_id): + self._pin_app(app_id) + elif event.button == 3: + # Show context menu for running app + app_id = client.get_app_id() + self.show_menu(app_id, client) + self.menu.popup_at_pointer(event) + + def on_app_id(*_): + app_id = client.get_app_id() + if not app_id: + return + + client_image.set_from_pixbuf( + self.icon_resolver.get_icon_pixbuf(app_id, self.icon_size) + ) + + title = client.get_title() + client_button.set_tooltip_text(title if title else app_id) + + def create_glace_hover_handlers(button): + def on_enter(*_): + self.handle_item_hovered(button, False) + if data.DOCK_PREVIEW_APPS: + self.update_preview_image_glace(client, button) + + def on_leave(*_): + self.handle_item_unhovered(button, False) + if data.DOCK_PREVIEW_APPS: + self.popup_revealer.unreveal() + + return on_enter, on_leave + + client_button = Button( + name="dock-app-button", + image=client_image, + events=["enter-notify", "leave-notify"], + on_button_press_event=lambda _, event: on_button_press_event( + event, client + ), ) - for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"): - self.conn.connect(f"event::{ev}", self.update_dock) + # Connect hover handlers after button creation + hover_enter, hover_leave = create_glace_hover_handlers(client_button) + client_button.connect("enter-notify-event", hover_enter) + client_button.connect("leave-notify-event", hover_leave) - self.conn.connect("event::workspace", self.check_hide) + self.glace_client_buttons[client.get_id()] = client_button + # Add to running items position tracking + self.running_items_pos.append(client_button) - GLib.timeout_add_seconds(1, self.check_config_change) + bulk_connect( + client, + { + "notify::app-id": on_app_id, + "close": lambda *_: self._remove_glace_client_button(client), + }, + ) - def _build_app_identifiers_map(self): - identifiers = {} - for app in self._all_apps: - if app.name: - identifiers[app.name.lower()] = app - if app.display_name: - identifiers[app.display_name.lower()] = app - if app.window_class: - identifiers[app.window_class.lower()] = app - if app.executable: - identifiers[app.executable.split("/")[-1].lower()] = app - if app.command_line: - identifiers[app.command_line.split()[0].split("/")[-1].lower()] = app - return identifiers + self.running_apps_container.add(client_button) - def _normalize_window_class(self, class_name): - if not class_name: - return "" - normalized = class_name.lower() - suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"] - for suffix in suffixes: - if normalized.endswith(suffix): - normalized = normalized[: -len(suffix)] - return normalized + except Exception: + pass - def _classes_match(self, class1, class2): - if not class1 or not class2: - return False - norm1 = self._normalize_window_class(class1) - norm2 = self._normalize_window_class(class2) - return norm1 == norm2 + def _on_glace_client_removed(self, _, client): + self._remove_glace_client_button(client) - def on_drag_begin(self, widget, drag_context): - self._drag_in_progress = True - Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget)) + def _remove_glace_client_button(self, client): + try: + client_id = client.get_id() + if client_id in self.glace_client_buttons: + button = self.glace_client_buttons[client_id] + # Remove from position tracking + if button in self.running_items_pos: + self.running_items_pos.remove(button) + self.running_apps_container.remove(button) + del self.glace_client_buttons[client_id] + except Exception: + pass - def _on_hover_enter(self, *args): - self.is_mouse_over_dock_area = True - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - self.dock_revealer.set_reveal_child(True) - if not self.always_occluded: - self.dock_full.remove_style_class("occluded") + def _populate_pinned_apps(self, apps): + self.pinned_apps_container.children = [] + # Clear pinned items position tracking + self.pinned_items_pos = [] - def _on_hover_leave(self, *args): - self.is_mouse_over_dock_area = False - self.delay_hide() + try: + desktop_apps = get_desktop_applications(include_hidden=False) + except Exception: + desktop_apps = [] - def _on_dock_enter(self, widget, event): - self.is_mouse_over_dock_area = True - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - self.dock_revealer.set_reveal_child(True) - if not self.always_occluded: - self.dock_full.remove_style_class("occluded") - return True + for app_id in apps: + app = self._find_desktop_app(app_id, desktop_apps) + if app: - def _on_dock_leave(self, widget, event): - if event.detail == Gdk.NotifyType.INFERIOR: - return False + def on_button_press_event(_, event, app=app, app_id=app_id): + if event.button == 1: # Left click + self._launch_app(app) + elif event.button == 2: # Middle click + # Unpin the app from dock + self._unpin_app(app_id) + elif event.button == 3: # Right click + self.show_menu(app_id) + self.menu.popup_at_pointer(event) - self.is_mouse_over_dock_area = False - self.delay_hide() + def create_hover_handlers(button): + return ( + lambda *_: self.handle_item_hovered(button, True), + lambda *_: self.handle_item_unhovered(button, True), + ) - if self.always_occluded: - self.dock_full.add_style_class("occluded") - return True + pinned_button = Button( + name="dock-app-button", + image=Image(pixbuf=app.get_icon_pixbuf(self.icon_size)), + tooltip_text=app.display_name or app.name, + events=["enter-notify", "leave-notify"], + on_button_press_event=on_button_press_event, + ) - def find_app(self, app_identifier): - if not app_identifier: - return None - if isinstance(app_identifier, dict): - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: - if key in app_identifier and app_identifier[key]: - app = self.find_app_by_key(app_identifier[key]) - if app: - return app - return None - return self.find_app_by_key(app_identifier) + # Connect hover handlers after button creation + hover_enter, hover_leave = create_hover_handlers(pinned_button) + pinned_button.connect("enter-notify-event", hover_enter) + pinned_button.connect("leave-notify-event", hover_leave) - def find_app_by_key(self, key_value): - if not key_value: - return None - normalized_id = str(key_value).lower() - if normalized_id in self.app_identifiers: - return self.app_identifiers[normalized_id] - for app in self._all_apps: - if app.name and normalized_id in app.name.lower(): - return app - if app.display_name and normalized_id in app.display_name.lower(): - return app - if app.window_class and normalized_id in app.window_class.lower(): - return app - if app.executable and normalized_id in app.executable.lower(): - return app - if app.command_line and normalized_id in app.command_line.lower(): + # Add to pinned items position tracking + self.pinned_items_pos.append(pinned_button) + self.pinned_apps_container.add(pinned_button) + + def _find_desktop_app(self, app_id: str, desktop_apps): + for app in desktop_apps: + + if ( + (app.name and app.name.lower() == app_id.lower()) + or (app.display_name and app.display_name.lower() == app_id.lower()) + or ( + hasattr(app, "window_class") + and app.window_class + and app.window_class.lower() == app_id.lower() + ) + or (app.executable and app.executable.lower() == app_id.lower()) + or ( + app.executable + and os.path.basename(app.executable).lower() == app_id.lower() + ) + ): return app return None - def update_app_map(self): - self._all_apps = get_desktop_applications() - self.app_map = {app.name: app for app in self._all_apps if app.name} - self.app_identifiers = self._build_app_identifiers_map() - - def create_button(self, app_identifier, instances, workspace: str): - desktop_app = self.find_app(app_identifier) - # print("Creating button for app identifier:", app_identifier) - icon_img = None - display_name = None - - if desktop_app: - icon_img = desktop_app.get_icon_pixbuf(size=self.icon_size) - display_name = desktop_app.display_name or desktop_app.name - - id_value = ( - app_identifier["name"] - if isinstance(app_identifier, dict) - else app_identifier - ) - - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size) - - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf( - "application-x-executable-symbolic", self.icon_size - ) - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf( - "image-missing", self.icon_size - ) - - items = [Image(pixbuf=icon_img)] - - # Add dot indicator for open apps - if instances: - dot = Box( - name="dock-app-dot", - h_align="center", - v_align="center", - ) - dot.set_size_request(4, 4) - items.append(dot) - - tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown") - if not display_name and instances and instances[0].get("title"): - tooltip = instances[0]["title"] - - button = Button( - child=Box( - name="dock-icon", orientation="v", h_align="center", children=items - ), - on_clicked=lambda *a: self.handle_app( - app_identifier, instances, desktop_app - ), - tooltip_text=tooltip, - name="dock-app-button", - ) - button.app_identifier = app_identifier - button.desktop_app = desktop_app - button.instances = instances - if instances: - button.add_style_class("instance") - - button.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - button.connect("drag-begin", self.on_drag_begin) - button.connect("drag-end", self.on_drag_end) - button.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - button.connect("drag-data-get", self.on_drag_data_get) - button.connect("drag-data-received", self.on_drag_data_received) - button.connect("enter-notify-event", self._on_child_enter) - button.connect("enter-notify-event", self._on_icon_hover_enter) - button.connect("leave-notify-event", self._on_icon_hover_leave) - overlay = Overlay( - overlays=Label( - label=workspace, - name="dock-app-overlay-label", - h_align="end", - v_align="start", - visible=bool(workspace), - ), - child=button, - name="dock-app-overlay", - ) - return overlay - - def handle_app(self, app_identifier, instances, desktop_app=None): - if not instances: - if not desktop_app: - desktop_app = self.find_app(app_identifier) - if desktop_app: - launch_success = desktop_app.launch() - if not launch_success: - if desktop_app.command_line: - exec_shell_command_async(f"nohup {desktop_app.command_line} &") - elif desktop_app.executable: - exec_shell_command_async(f"nohup {desktop_app.executable} &") - else: - cmd_to_run = None - if isinstance(app_identifier, dict): - if ( - "command_line" in app_identifier - and app_identifier["command_line"] - ): - cmd_to_run = app_identifier["command_line"] - elif ( - "executable" in app_identifier and app_identifier["executable"] - ): - cmd_to_run = app_identifier["executable"] - elif "name" in app_identifier and app_identifier["name"]: - cmd_to_run = app_identifier["name"] - elif isinstance(app_identifier, str): - cmd_to_run = app_identifier - if cmd_to_run: - exec_shell_command_async(f"nohup {cmd_to_run} &") - else: - # Since we now have individual buttons for each instance, - # directly focus the specific instance instead of cycling - if instances and len(instances) == 1: - instance = instances[0] - exec_shell_command( - f"hyprctl dispatch focuswindow address:{instance['address']}" - ) - - def _on_icon_hover_enter(self, widget, event): - """Handle icon hover enter - enlarge the icon""" - widget.add_style_class("dock-icon-hover") - # Find the image widget inside the button and scale it - dock_icon = self._find_dock_icon_image(widget) - if dock_icon: - # Create a larger version of the icon - enlarged_size = int(self.icon_size * 1.3) # 30% larger - if hasattr(widget, 'desktop_app') and widget.desktop_app: - enlarged_pixbuf = widget.desktop_app.get_icon_pixbuf(size=enlarged_size) - if enlarged_pixbuf: - dock_icon.set_from_pixbuf(enlarged_pixbuf) - elif hasattr(widget, 'app_identifier'): - id_value = ( - widget.app_identifier["name"] - if isinstance(widget.app_identifier, dict) - else widget.app_identifier - ) - enlarged_pixbuf = self.icon_resolver.get_icon_pixbuf(id_value, enlarged_size) - if enlarged_pixbuf: - dock_icon.set_from_pixbuf(enlarged_pixbuf) - return False - - def _on_icon_hover_leave(self, widget, event): - """Handle icon hover leave - restore original icon size""" - widget.remove_style_class("dock-icon-hover") - # Find the image widget inside the button and restore normal size - dock_icon = self._find_dock_icon_image(widget) - if dock_icon: - # Restore original size icon - if hasattr(widget, 'desktop_app') and widget.desktop_app: - normal_pixbuf = widget.desktop_app.get_icon_pixbuf(size=self.icon_size) - if normal_pixbuf: - dock_icon.set_from_pixbuf(normal_pixbuf) - elif hasattr(widget, 'app_identifier'): - id_value = ( - widget.app_identifier["name"] - if isinstance(widget.app_identifier, dict) - else widget.app_identifier - ) - normal_pixbuf = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size) - if normal_pixbuf: - dock_icon.set_from_pixbuf(normal_pixbuf) - return False - - def _find_dock_icon_image(self, button_widget): - """Find the Image widget inside a dock button""" - def find_image_recursive(widget): - if isinstance(widget, Image): - return widget - if hasattr(widget, 'get_children'): - for child in widget.get_children(): - result = find_image_recursive(child) - if result: - return result - elif hasattr(widget, 'get_child'): - child = widget.get_child() - if child: - return find_image_recursive(child) - return None - - return find_image_recursive(button_widget) - - def _on_child_enter(self, widget, event): - self.is_mouse_over_dock_area = True - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - return False - - def delay_hide(self): - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered) - - def hide_dock_if_not_hovered(self): - self.hide_id = None - if ( - not self.is_mouse_over_dock_area - and not self._drag_in_progress - and not self._prevent_occlusion - ): - if self.always_occluded: - self.dock_revealer.set_reveal_child(False) - else: - occlusion_region = ("bottom", self.effective_occlusion_size) - if check_occlusion(occlusion_region) or not self.view.get_children(): - self.dock_revealer.set_reveal_child(False) - return False - - def check_hide(self, *args): - if ( - self.is_mouse_over_dock_area - or self._drag_in_progress - or self._prevent_occlusion - ): - return - - clients = self.get_clients() - current_ws = self.get_workspace() - ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] - - if not self.always_occluded: - if not ws_clients: - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - self.dock_full.remove_style_class("occluded") - elif any( - not w.get("floating") and not w.get("fullscreen") for w in ws_clients - ): - self.check_occlusion_state() - else: - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - self.dock_full.remove_style_class("occluded") - else: - if self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(False) - self.dock_full.add_style_class("occluded") - - def update_dock(self, *args): - self.update_app_map() - arranger_handler = getattr(self, "_arranger_handler", None) - if arranger_handler: - remove_handler(arranger_handler) - clients = self.get_clients() - - running_windows = {} - for c in clients: - window_id = None - if class_name := c.get("initialClass", "").lower(): - window_id = class_name - elif class_name := c.get("class", "").lower(): - window_id = class_name - elif title := c.get("title", "").lower(): - possible_name = title.split(" - ")[0].strip() - if possible_name and len(possible_name) > 1: - window_id = possible_name - else: - window_id = title - if not window_id: - window_id = "unknown-app" - running_windows.setdefault(window_id, []).append(c) - normalized_id = self._normalize_window_class(window_id) - if normalized_id != window_id: - running_windows.setdefault(normalized_id, []).extend( - running_windows[window_id] - ) - - pinned_buttons = [] - used_window_classes = set() - used_instances = set() - - for app_data_item in self.pinned: - app = self.find_app(app_data_item) - instances = [] - matched_class = None - possible_identifiers = [] - - if isinstance(app_data_item, dict): - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: - if key in app_data_item and app_data_item[key]: - possible_identifiers.append(app_data_item[key].lower()) - elif isinstance(app_data_item, str): - possible_identifiers.append(app_data_item.lower()) - - if app: - if app.window_class: - possible_identifiers.append(app.window_class.lower()) - if app.executable: - possible_identifiers.append(app.executable.split("/")[-1].lower()) - if app.command_line: - cmd_parts = app.command_line.split() - if cmd_parts: - possible_identifiers.append(cmd_parts[0].split("/")[-1].lower()) - if app.name: - possible_identifiers.append(app.name.lower()) - if app.display_name: - possible_identifiers.append(app.display_name.lower()) - - possible_identifiers = list(set(possible_identifiers)) - - for identifier in possible_identifiers: - if identifier in running_windows: - instances = running_windows[identifier] - matched_class = identifier - break - normalized = self._normalize_window_class(identifier) - if normalized in running_windows: - instances = running_windows[normalized] - matched_class = normalized - break - for window_class_key in running_windows: - if len(identifier) >= 3 and identifier in window_class_key: - instances = running_windows[window_class_key] - matched_class = window_class_key - break - if matched_class: - break - - if matched_class: - used_window_classes.add(matched_class) - used_window_classes.add(self._normalize_window_class(matched_class)) - - # Create separate buttons for each instance of pinned apps - if instances: - for instance in instances: - used_instances.add(instance.get("address")) - workspace = str(instance.get("workspace", {}).get("id", "")) - pinned_buttons.append( - self.create_button(app_data_item, [instance], workspace) - ) - else: - # No instances running, create button without instances - pinned_buttons.append(self.create_button(app_data_item, [], "")) - - open_buttons = [] - for class_name, instances in running_windows.items(): - if class_name not in used_window_classes: - app = None - app = self.app_identifiers.get(class_name) - if not app: - norm_class = self._normalize_window_class(class_name) - app = self.app_identifiers.get(norm_class) - if not app: - app = self.find_app_by_key(class_name) - if not app and instances and instances[0].get("title"): - title = instances[0].get("title", "") - potential_name = title.split(" - ")[0].strip() - if len(potential_name) > 2: - app = self.find_app_by_key(potential_name) - - if app: - app_data_obj = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - identifier = app_data_obj - else: - identifier = class_name - - # Create separate buttons for each instance of unpinned apps - for instance in instances: - if instance.get("address") not in used_instances: - workspace = str(instance.get("workspace", {}).get("id", "")) - open_buttons.append( - self.create_button(identifier, [instance], workspace) - ) - - children = pinned_buttons - separator_orientation = ( - Gtk.Orientation.VERTICAL - if self.view.get_orientation() == Gtk.Orientation.HORIZONTAL - else Gtk.Orientation.HORIZONTAL - ) - if pinned_buttons and open_buttons: - children += [ - Box( - orientation=separator_orientation, - v_expand=False, - h_expand=False, - h_align="center", - v_align="center", - name="dock-separator", - ) - ] - children += open_buttons - - self.view.children = children - idle_add(self._update_size) - self._drag_in_progress = False - self.check_occlusion_state() - - def _update_size(self): - width, _ = self.view.get_preferred_width() - self.set_size_request(width, -1) - return False - - def get_clients(self): + def _launch_app(self, app): try: - return json.loads(self.conn.send_command("j/clients").reply.decode()) - except json.JSONDecodeError: - return [] + cleaned_command = re.sub(r"%\w+", "", app.command_line).strip() + final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" + subprocess.Popen(final_command, shell=True) - def get_focused(self): - try: - return json.loads( - self.conn.send_command("j/activewindow").reply.decode() - ).get("address", "") - except json.JSONDecodeError: - return "" - - def get_workspace(self): - try: - return json.loads( - self.conn.send_command("j/activeworkspace").reply.decode() - ).get("id", 0) - except json.JSONDecodeError: - return 0 - - def check_occlusion_state(self): - if ( - self.is_mouse_over_dock_area - or self._drag_in_progress - or self._prevent_occlusion - ): - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - if not self.always_occluded: - self.dock_full.remove_style_class("occluded") - return True - - if self.always_occluded: - if self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(False) - self.dock_full.add_style_class("occluded") - return True - - occlusion_region = ("bottom", self.effective_occlusion_size) - is_occluded_by_window = check_occlusion(occlusion_region) - is_empty = not self.view.get_children() - - if is_occluded_by_window or is_empty: - if self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(False) - self.dock_full.add_style_class("occluded") - else: - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - self.dock_full.remove_style_class("occluded") - - return True - - def _find_drag_target(self, widget): - children = self.view.get_children() - while widget is not None and widget not in children: - widget = widget.get_parent() if hasattr(widget, "get_parent") else None - return widget - - def on_drag_data_get(self, widget, drag_context, data_obj, info, time): - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) - if target is not None: - index = self.view.get_children().index(target) - data_obj.set_text(str(index), -1) - - def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time): - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) - if target is None: - return - try: - source_index = int(data_obj.get_text()) - except (TypeError, ValueError): - return - - children = self.view.get_children() - try: - target_index = children.index(target) - except ValueError: - return - - if source_index != target_index: - separator_index = -1 - for i, child_item_loop in enumerate(children): - if child_item_loop.get_name() == "dock-separator": - separator_index = i - break - cross_section_drag = separator_index != -1 and ( - (source_index < separator_index and target_index > separator_index) - or (source_index > separator_index and target_index < separator_index) - ) - - child_item_to_move = children.pop(source_index) - children.insert(target_index, child_item_to_move) - self.view.children = children - self.update_pinned_apps(skip_update=not cross_section_drag) - if cross_section_drag: - GLib.idle_add(self.update_dock) - - def on_drag_end(self, widget, drag_context): - if not self._drag_in_progress: - return - - def process_drag_end(): - display = Gdk.Display.get_default() - _, x, y, _ = display.get_pointer() - - # Get the widget's allocation to check if drag ended outside - alloc = self.view.get_allocation() - widget_x = alloc.x - widget_y = alloc.y - widget_width = alloc.width - widget_height = alloc.height - - # Check if pointer is outside the dock area - if not ( - widget_x <= x <= widget_x + widget_width - and widget_y <= y <= widget_y + widget_height - ): - app_id_dragged = widget.app_identifier - instances_dragged = widget.instances - - # Remove pinned app - app_index_dragged = -1 - for i, pinned_app_item in enumerate(self.pinned): - if isinstance(app_id_dragged, dict) and isinstance( - pinned_app_item, dict - ): - if app_id_dragged.get("name") == pinned_app_item.get("name"): - app_index_dragged = i - break - elif app_id_dragged == pinned_app_item: - app_index_dragged = i - break - - if app_index_dragged >= 0: - self.pinned.pop(app_index_dragged) - self.config["pinned_apps"] = self.pinned - self.update_pinned_apps_file() - self.update_dock() - elif instances_dragged: - address = instances_dragged[0].get("address") - if address: - exec_shell_command( - f"hyprctl dispatch focuswindow address:{address}" - ) - - self._drag_in_progress = False - self.check_occlusion_state() - - GLib.idle_add(process_drag_end) - - def check_config_change(self): - new_config = read_config() - new_always_occluded = data.DOCK_ALWAYS_OCCLUDED - if self.always_occluded != new_always_occluded: - self.always_occluded = new_always_occluded - self.check_occlusion_state() - - if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): - self.config = new_config - self.pinned = self.config.get("pinned_apps", []) - self.update_app_map() - self.update_dock() - return True - - def update_pinned_apps_file(self): - config_path = get_relative_path("../config/assets/dock.json") - try: - with open(config_path, "w") as file: - json.dump(self.config, file, indent=4) - return True except Exception as e: - logging.error(f"Failed to write dock config: {e}") + try: + app.launch() + except Exception as fallback_error: + pass + + def check_if_pinned(self, app_id: str) -> bool: + return app_id in self.pinned_apps + + def show_menu(self, app_id: str, client=None, window_address=None): + for item in self.menu.get_children(): + self.menu.remove(item) + item.destroy() + + if client or window_address: + close_item = Gtk.MenuItem(label="Close") + if client: + close_item.connect( + "activate", lambda *_: self._close_running_app(client) + ) + self.menu.add(close_item) + + # Add separator if we have both close and pin options + if app_id: + separator = Gtk.SeparatorMenuItem() + self.menu.add(separator) + + # Add Pin/Unpin option (for both pinned and running apps) + if app_id: + pin_item = Gtk.MenuItem(label="Pin") + if self.check_if_pinned(app_id): + pin_item.set_label("Unpin") + pin_item.connect("activate", lambda *_: self._unpin_app(app_id)) + else: + pin_item.connect("activate", lambda *_: self._pin_app(app_id)) + self.menu.add(pin_item) + + self.menu.show_all() + + def _close_running_app(self, client): + try: + # Try to close the client gracefully first + client.close() + except Exception: + # If that fails, try to get the app_id and use hyprctl to kill the window + try: + app_id = client.get_app_id() + if app_id: + # Use hyprctl to kill windows of this application class + exec_shell_command(f"hyprctl dispatch closewindow class:{app_id}") + except Exception: + # Last resort: kill active window (not ideal but better than nothing) + try: + exec_shell_command("hyprctl dispatch killactive") + except Exception: + pass + + def _unpin_app(self, app_id: str): + if not self.check_if_pinned(app_id): return False - def update_pinned_apps(self, skip_update=False): - pinned_children_data = [] - for child_widget in self.view.get_children(): - if child_widget.get_name() == "dock-separator": - break - if hasattr(child_widget, "app_identifier"): - if hasattr(child_widget, "desktop_app") and child_widget.desktop_app: - app = child_widget.desktop_app - app_data_obj = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - pinned_children_data.append(app_data_obj) + self.pinned_apps.remove(app_id) + write_json_file(self.pinned_apps, PINNED_APPS_FILE) + self._populate_pinned_apps(self.pinned_apps) + self._update_modus_service_dock_apps() + return True + + def _pin_app(self, app_id: str): + if self.check_if_pinned(app_id): + return False + + self.pinned_apps.append(app_id) + write_json_file(self.pinned_apps, PINNED_APPS_FILE) + self._populate_pinned_apps(self.pinned_apps) + self._update_modus_service_dock_apps() + return True + + def update_dock(self, *args): + try: + if hasattr(self, "running_apps_container"): + self.running_apps_container.show_all() + + except Exception as e: + pass + + def _update_modus_service_dock_apps(self): + if modus_service: + try: + dock_apps_json = json.dumps(self.pinned_apps) + modus_service.dock_apps = dock_apps_json + except Exception as e: + pass + + def _on_dock_apps_changed(self, service, new_dock_apps: str): + new_pinned_apps = json.loads(new_dock_apps) if new_dock_apps else [] + if new_pinned_apps != self.pinned_apps: + self.pinned_apps = new_pinned_apps + write_json_file(self.pinned_apps, PINNED_APPS_FILE) + self._populate_pinned_apps(self.pinned_apps) + + def _on_workspace_changed(self, service, new_workspace: str): + self.update_dock() + + def _on_active_app_changed(self, service, new_active_app: str): + self.update_dock() + + +class Dock(Window): + def __init__(self): + self.dock_enabled = data.DOCK_ENABLED + anchor_map = { + "Bottom": "bottom center", + "Left": "left center", + "Right": "right center", + } + anchor = anchor_map.get(data.DOCK_POSITION, "bottom center") + + super().__init__( + layer="top", + title="modus", + anchor=anchor, + ) + if data.DOCK_POSITION == "Left": + transition_type = "slide-right" + padding_style = "padding: 50px 5px 50px 20px;" + elif data.DOCK_POSITION == "Right": + transition_type = "slide-left" + padding_style = "padding: 50px 20px 50px 5px;" + else: # Bottom + transition_type = "slide-up" + padding_style = "padding: 20px 50px 5px 50px;" + + self.app_bar = AppBar(self) + self.revealer = Revealer( + child=Box(children=[self.app_bar], style=padding_style), + transition_duration=500, + transition_type=transition_type, + ) + + if data.DOCK_AUTO_HIDE: + self.children = EventBox( + events=["enter-notify", "leave-notify"], + child=Box(style="min-height: 1px", children=self.revealer), + on_enter_notify_event=lambda *_: self.on_hover_enter(), + on_leave_notify_event=lambda *_: self.on_hover_leave(), + ) + else: + self.children = EventBox( + events=["enter-notify", "leave-notify"], + child=Box(children=self.revealer, style="min-height: 60px;"), + on_leave_notify_event=lambda *_: self.on_hover_leave_no_autohide(), + ) + + if data.DOCK_AUTO_HIDE: + self.revealer.set_reveal_child(False) + else: + self.revealer.set_reveal_child(True) + + if not data.DOCK_ENABLED: + self.set_visible(False) + else: + self.set_visible(True) + + # Set up occlusion checking + self.dock_height = 100 + self.is_hovered = False + self.hide_timeout_id = None + + # Only setup occlusion monitoring if auto-hide is enabled + if data.DOCK_AUTO_HIDE: + self.setup_occlusion_monitoring() + + # Connect to modus service for dock visibility control + if modus_service: + modus_service.connect("dock-hidden-changed", self._on_dock_hidden_changed) + modus_service.connect("dock-width-changed", self._on_dock_width_changed) + modus_service.connect("dock-height-changed", self._on_dock_height_changed) + + def on_hover_enter(self): + self.is_hovered = True + if self.hide_timeout_id: + GLib.source_remove(self.hide_timeout_id) + self.hide_timeout_id = None + self.revealer.set_reveal_child(True) + + def on_hover_leave(self): + self.is_hovered = False + if hasattr(self, "app_bar"): + self.app_bar.clear_all_hover_effects() + + def on_hover_leave_no_autohide(self): + if hasattr(self, "app_bar"): + self.app_bar.clear_all_hover_effects() + + def setup_occlusion_monitoring(self): + def check_dock_occlusion(): + try: + if data.DOCK_POSITION == "Left": + position = ("left", self.dock_height) + elif data.DOCK_POSITION == "Right": + position = ("right", self.dock_height) + else: # Bottom + position = ("bottom", self.dock_height) + + if data.DOCK_ALWAYS_OCCLUDED: + is_occluded = True else: - pinned_children_data.append(child_widget.app_identifier) + is_occluded = check_occlusion(position) - self.config["pinned_apps"] = pinned_children_data - self.pinned = pinned_children_data - file_updated = self.update_pinned_apps_file() - if file_updated and not skip_update: - self.update_dock() + current_visible = self.revealer.get_reveal_child() - @staticmethod - def notify_config_change(): - for dock_instance in Dock._instances: - GLib.idle_add(dock_instance.check_config_change_immediate) + if is_occluded and not self.is_hovered and current_visible: + self.revealer.set_reveal_child(False) + elif not is_occluded and not current_visible and not self.is_hovered: + self.revealer.set_reveal_child(True) - def check_config_change_immediate(self): - new_config = read_config() + except Exception as e: + pass - previous_always_occluded = self.always_occluded - self.always_occluded = data.DOCK_ALWAYS_OCCLUDED + return True - if previous_always_occluded != self.always_occluded: - self.check_occlusion_state() + GLib.timeout_add(250, check_dock_occlusion) - if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): - self.config = new_config - self.pinned = self.config.get("pinned_apps", []) - self.update_app_map() - self.update_dock() + def _update_size(self): + try: + if hasattr(self, "revealer") and self.revealer.get_child(): + app_bar = self.revealer.get_child().get_children()[0] + if hasattr(app_bar, "get_preferred_width"): + width, _ = app_bar.get_preferred_width() + height, _ = app_bar.get_preferred_height() + self.set_size_request(width, height) + except Exception as e: + pass return False - @staticmethod - def update_visibility(visible): - for dock in Dock._instances: - dock.set_visible(visible) - if visible: - GLib.idle_add(dock.check_occlusion_state) + def _on_dock_hidden_changed(self, service, hidden: bool): + try: + if hidden: + self.revealer.set_reveal_child(False) + self.set_visible(False) else: - if ( - hasattr(dock, "dock_revealer") - and dock.dock_revealer.get_reveal_child() - ): - dock.dock_revealer.set_reveal_child(False) + self.set_visible(True) + self.revealer.set_reveal_child(True) + except Exception as e: + pass + + def _on_dock_width_changed(self, service, width: int): + try: + # Could be used to adjust dock width dynamically + # For now, just log the change + pass + except Exception as e: + pass + + def _on_dock_height_changed(self, service, height: int): + try: + self.dock_height = height if height > 0 else 100 + except Exception as e: + pass + + def reload_data(self): + if not data.DOCK_ENABLED and self.dock_enabled: + self.set_visible(False) + self.dock_enabled = False + elif data.DOCK_ENABLED and not self.dock_enabled: + self.set_visible(True) + self.dock_enabled = True + # Update auto-hide behavior + if hasattr(self, "revealer"): + if data.DOCK_AUTO_HIDE: + if not hasattr(self, "is_hovered"): + self.setup_occlusion_monitoring() + else: + self.revealer.set_reveal_child(True) diff --git a/modules/example_dock.py b/modules/example_dock.py deleted file mode 100644 index 404d25a..0000000 --- a/modules/example_dock.py +++ /dev/null @@ -1,1010 +0,0 @@ -import json -import logging - -import cairo -from fabric.widgets.widget import Widget -from gi.repository import Gdk, GLib, Gtk - -import config.data as data -from fabric.hyprland.widgets import get_hyprland_connection -from fabric.utils import ( - exec_shell_command, - exec_shell_command_async, - get_relative_path, - idle_add, - remove_handler, -) -from fabric.utils.helpers import get_desktop_applications -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.eventbox import EventBox -from fabric.widgets.overlay import Overlay -from fabric.widgets.image import Image -from fabric.widgets.label import Label -from fabric.widgets.revealer import Revealer -from modules.corners import MyCorner -from utils.icon_resolver import IconResolver -from utils.occlusion import check_occlusion -from widgets.wayland import WaylandWindow as Window - -# TODO: use modus_service - - -def read_config(): - """Read and return the full configuration from the JSON file, handling missing file.""" - config_path = get_relative_path("../config/assets/dock.json") - try: - with open(config_path, "r") as file: - config_data = json.load(file) - - if ( - "pinned_apps" in config_data - and config_data["pinned_apps"] - and isinstance(config_data["pinned_apps"][0], str) - ): - all_apps = get_desktop_applications() - app_map = {app.name: app for app in all_apps if app.name} - - old_pinned = config_data["pinned_apps"] - config_data["pinned_apps"] = [] - - for app_id in old_pinned: - app = app_map.get(app_id) - if app: - app_data_obj = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - config_data["pinned_apps"].append(app_data_obj) - else: - config_data["pinned_apps"].append({"name": app_id}) - - except (FileNotFoundError, json.JSONDecodeError): - config_data = {"pinned_apps": []} - return config_data - - -def createSurfaceFromWidget(widget: Gtk.Widget) -> cairo.ImageSurface: - alloc = widget.get_allocation() - surface = cairo.ImageSurface( - cairo.Format.ARGB32, - alloc.width, - alloc.height, - ) - cr = cairo.Context(surface) - cr.set_source_rgba(255, 255, 255, 0) - cr.rectangle(0, 0, alloc.width, alloc.height) - cr.fill() - widget.draw(cr) - return surface - - -class Dock(Window): - _instances = [] - - def __init__(self, **kwargs): - self.icon_size = data.DOCK_ICON_SIZE - self.effective_occlusion_size = 36 + self.icon_size - - # Dock is always positioned at the bottom - anchor_to_set = "bottom" - revealer_transition_type = "slide-up" - main_box_orientation_val = Gtk.Orientation.VERTICAL - main_box_h_align_val = "center" - dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL - - self.actual_dock_is_horizontal = True - - super().__init__( - name="dock-window", - layer="top", - anchor=anchor_to_set, - margin="0px 0px 0px 0px", - exclusivity="none", - **kwargs, - ) - Dock._instances.append(self) - - self.config = read_config() - self.conn = get_hyprland_connection() - self.icon_resolver = IconResolver() - self.pinned = self.config.get("pinned_apps", []) - self.config_path = get_relative_path("../config/assets/dock.json") - self.app_map = {} - self._all_apps = get_desktop_applications() - self.app_identifiers = self._build_app_identifiers_map() - - self.hide_id = None - self._arranger_handler = None - self._drag_in_progress = False - self.always_occluded = data.DOCK_ALWAYS_OCCLUDED - self.is_mouse_over_dock_area = False - self._prevent_occlusion = False - - self.view = Box(name="viewport", spacing=4) - self.wrapper = Box( - name="dock", - children=[self.view], - ) - - self.wrapper.set_orientation(dock_wrapper_orientation_val) - self.view.set_orientation(dock_wrapper_orientation_val) - - # Dock is always horizontal, so remove vertical style class - self.wrapper.remove_style_class("vertical") - - # Always use Dense theme - self.wrapper.add_style_class("dense") - - self.dock_eventbox = EventBox() - self.dock_eventbox.add(self.wrapper) - self.dock_eventbox.connect("enter-notify-event", self._on_dock_enter) - self.dock_eventbox.connect("leave-notify-event", self._on_dock_leave) - - # Create corners for horizontal dock at bottom - self.corner_left = Box( - name="dock-corner-left", - orientation=Gtk.Orientation.VERTICAL, - h_align="start", - children=[ - Box(v_expand=True, v_align="fill"), - MyCorner("bottom-right"), - ], - ) - self.corner_right = Box( - name="dock-corner-right", - orientation=Gtk.Orientation.VERTICAL, - h_align="end", - children=[ - Box(v_expand=True, v_align="fill"), - MyCorner("bottom-left"), - ], - ) - self.corner_top = Box() - self.corner_bottom = Box() - - self.dock_full = Box( - name="dock-full", - orientation=Gtk.Orientation.HORIZONTAL, - h_expand=True, - h_align="fill", - children=[self.corner_left, self.dock_eventbox, self.corner_right], - ) - - self.dock_revealer = Revealer( - name="dock-revealer", - transition_type=revealer_transition_type, - transition_duration=250, - child_revealed=False, - child=self.dock_full, - ) - - self.hover_activator = EventBox() - - self.hover_activator.set_size_request(-1, 1) - self.hover_activator.connect("enter-notify-event", self._on_hover_enter) - self.hover_activator.connect("leave-notify-event", self._on_hover_leave) - - self.main_box = Box( - orientation=main_box_orientation_val, - children=[self.hover_activator, self.dock_revealer], - h_align=main_box_h_align_val, - ) - self.add(self.main_box) - - # Dense theme - hide corners - for corner in [ - self.corner_left, - self.corner_right, - self.corner_top, - self.corner_bottom, - ]: - corner.set_visible(False) - - if not data.DOCK_ENABLED: - self.set_visible(False) - - if self.always_occluded: - self.dock_full.add_style_class("occluded") - - self.view.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.view.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - self.view.connect("drag-data-get", self.on_drag_data_get) - self.view.connect("drag-data-received", self.on_drag_data_received) - self.view.connect("drag-begin", self.on_drag_begin) - self.view.connect("drag-end", self.on_drag_end) - - if self.conn.ready: - self.update_dock() - GLib.timeout_add(250, self.check_occlusion_state) - else: - self.conn.connect("event::ready", self.update_dock) - self.conn.connect( - "event::ready", - lambda *args: GLib.timeout_add(250, self.check_occlusion_state), - ) - - for ev in ("activewindow", "openwindow", "closewindow", "changefloatingmode"): - self.conn.connect(f"event::{ev}", self.update_dock) - - self.conn.connect("event::workspace", self.check_hide) - - GLib.timeout_add_seconds(1, self.check_config_change) - - def _build_app_identifiers_map(self): - identifiers = {} - for app in self._all_apps: - if app.name: - identifiers[app.name.lower()] = app - if app.display_name: - identifiers[app.display_name.lower()] = app - if app.window_class: - identifiers[app.window_class.lower()] = app - if app.executable: - identifiers[app.executable.split("/")[-1].lower()] = app - if app.command_line: - identifiers[app.command_line.split()[0].split("/")[-1].lower()] = app - return identifiers - - def _normalize_window_class(self, class_name): - if not class_name: - return "" - normalized = class_name.lower() - suffixes = [".bin", ".exe", ".so", "-bin", "-gtk"] - for suffix in suffixes: - if normalized.endswith(suffix): - normalized = normalized[: -len(suffix)] - return normalized - - def _classes_match(self, class1, class2): - if not class1 or not class2: - return False - norm1 = self._normalize_window_class(class1) - norm2 = self._normalize_window_class(class2) - return norm1 == norm2 - - def on_drag_begin(self, widget, drag_context): - self._drag_in_progress = True - Gtk.drag_set_icon_surface(drag_context, createSurfaceFromWidget(widget)) - - def _on_hover_enter(self, *args): - self.is_mouse_over_dock_area = True - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - self.dock_revealer.set_reveal_child(True) - if not self.always_occluded: - self.dock_full.remove_style_class("occluded") - - def _on_hover_leave(self, *args): - self.is_mouse_over_dock_area = False - self.delay_hide() - - def _on_dock_enter(self, widget, event): - self.is_mouse_over_dock_area = True - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - self.dock_revealer.set_reveal_child(True) - if not self.always_occluded: - self.dock_full.remove_style_class("occluded") - return True - - def _on_dock_leave(self, widget, event): - if event.detail == Gdk.NotifyType.INFERIOR: - return False - - self.is_mouse_over_dock_area = False - self.delay_hide() - - if self.always_occluded: - self.dock_full.add_style_class("occluded") - return True - - def find_app(self, app_identifier): - if not app_identifier: - return None - if isinstance(app_identifier, dict): - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: - if key in app_identifier and app_identifier[key]: - app = self.find_app_by_key(app_identifier[key]) - if app: - return app - return None - return self.find_app_by_key(app_identifier) - - def find_app_by_key(self, key_value): - if not key_value: - return None - normalized_id = str(key_value).lower() - if normalized_id in self.app_identifiers: - return self.app_identifiers[normalized_id] - for app in self._all_apps: - if app.name and normalized_id in app.name.lower(): - return app - if app.display_name and normalized_id in app.display_name.lower(): - return app - if app.window_class and normalized_id in app.window_class.lower(): - return app - if app.executable and normalized_id in app.executable.lower(): - return app - if app.command_line and normalized_id in app.command_line.lower(): - return app - return None - - def update_app_map(self): - self._all_apps = get_desktop_applications() - self.app_map = {app.name: app for app in self._all_apps if app.name} - self.app_identifiers = self._build_app_identifiers_map() - - def create_button(self, app_identifier, instances, workspace: str): - desktop_app = self.find_app(app_identifier) - # print("Creating button for app identifier:", app_identifier) - icon_img = None - display_name = None - - if desktop_app: - icon_img = desktop_app.get_icon_pixbuf(size=self.icon_size) - display_name = desktop_app.display_name or desktop_app.name - - id_value = ( - app_identifier["name"] - if isinstance(app_identifier, dict) - else app_identifier - ) - - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size) - - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf( - "application-x-executable-symbolic", self.icon_size - ) - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf( - "image-missing", self.icon_size - ) - - items = [Image(pixbuf=icon_img)] - - # Add dot indicator for open apps - if instances: - dot = Box( - name="dock-app-dot", - h_align="center", - v_align="center", - ) - dot.set_size_request(4, 4) - items.append(dot) - - tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown") - if not display_name and instances and instances[0].get("title"): - tooltip = instances[0]["title"] - - button = Button( - child=Box( - name="dock-icon", orientation="v", h_align="center", children=items - ), - on_clicked=lambda *a: self.handle_app( - app_identifier, instances, desktop_app - ), - tooltip_text=tooltip, - name="dock-app-button", - ) - button.app_identifier = app_identifier - button.desktop_app = desktop_app - button.instances = instances - if instances: - button.add_style_class("instance") - - button.drag_source_set( - Gdk.ModifierType.BUTTON1_MASK, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - button.connect("drag-begin", self.on_drag_begin) - button.connect("drag-end", self.on_drag_end) - button.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)], - Gdk.DragAction.MOVE, - ) - button.connect("drag-data-get", self.on_drag_data_get) - button.connect("drag-data-received", self.on_drag_data_received) - button.connect("enter-notify-event", self._on_child_enter) - button.connect("enter-notify-event", self._on_icon_hover_enter) - button.connect("leave-notify-event", self._on_icon_hover_leave) - overlay = Overlay( - overlays=Label( - label=workspace, - name="dock-app-overlay-label", - h_align="end", - v_align="start", - visible=bool(workspace), - ), - child=button, - name="dock-app-overlay", - ) - return overlay - - def handle_app(self, app_identifier, instances, desktop_app=None): - if not instances: - if not desktop_app: - desktop_app = self.find_app(app_identifier) - if desktop_app: - launch_success = desktop_app.launch() - if not launch_success: - if desktop_app.command_line: - exec_shell_command_async(f"nohup {desktop_app.command_line} &") - elif desktop_app.executable: - exec_shell_command_async(f"nohup {desktop_app.executable} &") - else: - cmd_to_run = None - if isinstance(app_identifier, dict): - if ( - "command_line" in app_identifier - and app_identifier["command_line"] - ): - cmd_to_run = app_identifier["command_line"] - elif ( - "executable" in app_identifier and app_identifier["executable"] - ): - cmd_to_run = app_identifier["executable"] - elif "name" in app_identifier and app_identifier["name"]: - cmd_to_run = app_identifier["name"] - elif isinstance(app_identifier, str): - cmd_to_run = app_identifier - if cmd_to_run: - exec_shell_command_async(f"nohup {cmd_to_run} &") - else: - # Since we now have individual buttons for each instance, - # directly focus the specific instance instead of cycling - if instances and len(instances) == 1: - instance = instances[0] - exec_shell_command( - f"hyprctl dispatch focuswindow address:{instance['address']}" - ) - - def _on_icon_hover_enter(self, widget, event): - """Handle icon hover enter - enlarge the icon""" - widget.add_style_class("dock-icon-hover") - # Find the image widget inside the button and scale it - dock_icon = self._find_dock_icon_image(widget) - if dock_icon: - # Create a larger version of the icon - enlarged_size = int(self.icon_size * 1.3) # 30% larger - if hasattr(widget, 'desktop_app') and widget.desktop_app: - enlarged_pixbuf = widget.desktop_app.get_icon_pixbuf(size=enlarged_size) - if enlarged_pixbuf: - dock_icon.set_from_pixbuf(enlarged_pixbuf) - elif hasattr(widget, 'app_identifier'): - id_value = ( - widget.app_identifier["name"] - if isinstance(widget.app_identifier, dict) - else widget.app_identifier - ) - enlarged_pixbuf = self.icon_resolver.get_icon_pixbuf(id_value, enlarged_size) - if enlarged_pixbuf: - dock_icon.set_from_pixbuf(enlarged_pixbuf) - return False - - def _on_icon_hover_leave(self, widget, event): - """Handle icon hover leave - restore original icon size""" - widget.remove_style_class("dock-icon-hover") - # Find the image widget inside the button and restore normal size - dock_icon = self._find_dock_icon_image(widget) - if dock_icon: - # Restore original size icon - if hasattr(widget, 'desktop_app') and widget.desktop_app: - normal_pixbuf = widget.desktop_app.get_icon_pixbuf(size=self.icon_size) - if normal_pixbuf: - dock_icon.set_from_pixbuf(normal_pixbuf) - elif hasattr(widget, 'app_identifier'): - id_value = ( - widget.app_identifier["name"] - if isinstance(widget.app_identifier, dict) - else widget.app_identifier - ) - normal_pixbuf = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size) - if normal_pixbuf: - dock_icon.set_from_pixbuf(normal_pixbuf) - return False - - def _find_dock_icon_image(self, button_widget): - """Find the Image widget inside a dock button""" - def find_image_recursive(widget): - if isinstance(widget, Image): - return widget - if hasattr(widget, 'get_children'): - for child in widget.get_children(): - result = find_image_recursive(child) - if result: - return result - elif hasattr(widget, 'get_child'): - child = widget.get_child() - if child: - return find_image_recursive(child) - return None - - return find_image_recursive(button_widget) - - def _on_child_enter(self, widget, event): - self.is_mouse_over_dock_area = True - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = None - return False - - def delay_hide(self): - if self.hide_id: - GLib.source_remove(self.hide_id) - self.hide_id = GLib.timeout_add(250, self.hide_dock_if_not_hovered) - - def hide_dock_if_not_hovered(self): - self.hide_id = None - if ( - not self.is_mouse_over_dock_area - and not self._drag_in_progress - and not self._prevent_occlusion - ): - if self.always_occluded: - self.dock_revealer.set_reveal_child(False) - else: - occlusion_region = ("bottom", self.effective_occlusion_size) - if check_occlusion(occlusion_region) or not self.view.get_children(): - self.dock_revealer.set_reveal_child(False) - return False - - def check_hide(self, *args): - if ( - self.is_mouse_over_dock_area - or self._drag_in_progress - or self._prevent_occlusion - ): - return - - clients = self.get_clients() - current_ws = self.get_workspace() - ws_clients = [w for w in clients if w["workspace"]["id"] == current_ws] - - if not self.always_occluded: - if not ws_clients: - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - self.dock_full.remove_style_class("occluded") - elif any( - not w.get("floating") and not w.get("fullscreen") for w in ws_clients - ): - self.check_occlusion_state() - else: - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - self.dock_full.remove_style_class("occluded") - else: - if self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(False) - self.dock_full.add_style_class("occluded") - - def update_dock(self, *args): - self.update_app_map() - arranger_handler = getattr(self, "_arranger_handler", None) - if arranger_handler: - remove_handler(arranger_handler) - clients = self.get_clients() - - running_windows = {} - for c in clients: - window_id = None - if class_name := c.get("initialClass", "").lower(): - window_id = class_name - elif class_name := c.get("class", "").lower(): - window_id = class_name - elif title := c.get("title", "").lower(): - possible_name = title.split(" - ")[0].strip() - if possible_name and len(possible_name) > 1: - window_id = possible_name - else: - window_id = title - if not window_id: - window_id = "unknown-app" - running_windows.setdefault(window_id, []).append(c) - normalized_id = self._normalize_window_class(window_id) - if normalized_id != window_id: - running_windows.setdefault(normalized_id, []).extend( - running_windows[window_id] - ) - - pinned_buttons = [] - used_window_classes = set() - used_instances = set() - - for app_data_item in self.pinned: - app = self.find_app(app_data_item) - instances = [] - matched_class = None - possible_identifiers = [] - - if isinstance(app_data_item, dict): - for key in [ - "window_class", - "executable", - "command_line", - "name", - "display_name", - ]: - if key in app_data_item and app_data_item[key]: - possible_identifiers.append(app_data_item[key].lower()) - elif isinstance(app_data_item, str): - possible_identifiers.append(app_data_item.lower()) - - if app: - if app.window_class: - possible_identifiers.append(app.window_class.lower()) - if app.executable: - possible_identifiers.append(app.executable.split("/")[-1].lower()) - if app.command_line: - cmd_parts = app.command_line.split() - if cmd_parts: - possible_identifiers.append(cmd_parts[0].split("/")[-1].lower()) - if app.name: - possible_identifiers.append(app.name.lower()) - if app.display_name: - possible_identifiers.append(app.display_name.lower()) - - possible_identifiers = list(set(possible_identifiers)) - - for identifier in possible_identifiers: - if identifier in running_windows: - instances = running_windows[identifier] - matched_class = identifier - break - normalized = self._normalize_window_class(identifier) - if normalized in running_windows: - instances = running_windows[normalized] - matched_class = normalized - break - for window_class_key in running_windows: - if len(identifier) >= 3 and identifier in window_class_key: - instances = running_windows[window_class_key] - matched_class = window_class_key - break - if matched_class: - break - - if matched_class: - used_window_classes.add(matched_class) - used_window_classes.add(self._normalize_window_class(matched_class)) - - # Create separate buttons for each instance of pinned apps - if instances: - for instance in instances: - used_instances.add(instance.get("address")) - workspace = str(instance.get("workspace", {}).get("id", "")) - pinned_buttons.append( - self.create_button(app_data_item, [instance], workspace) - ) - else: - # No instances running, create button without instances - pinned_buttons.append(self.create_button(app_data_item, [], "")) - - open_buttons = [] - for class_name, instances in running_windows.items(): - if class_name not in used_window_classes: - app = None - app = self.app_identifiers.get(class_name) - if not app: - norm_class = self._normalize_window_class(class_name) - app = self.app_identifiers.get(norm_class) - if not app: - app = self.find_app_by_key(class_name) - if not app and instances and instances[0].get("title"): - title = instances[0].get("title", "") - potential_name = title.split(" - ")[0].strip() - if len(potential_name) > 2: - app = self.find_app_by_key(potential_name) - - if app: - app_data_obj = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - identifier = app_data_obj - else: - identifier = class_name - - # Create separate buttons for each instance of unpinned apps - for instance in instances: - if instance.get("address") not in used_instances: - workspace = str(instance.get("workspace", {}).get("id", "")) - open_buttons.append( - self.create_button(identifier, [instance], workspace) - ) - - children = pinned_buttons - separator_orientation = ( - Gtk.Orientation.VERTICAL - if self.view.get_orientation() == Gtk.Orientation.HORIZONTAL - else Gtk.Orientation.HORIZONTAL - ) - if pinned_buttons and open_buttons: - children += [ - Box( - orientation=separator_orientation, - v_expand=False, - h_expand=False, - h_align="center", - v_align="center", - name="dock-separator", - ) - ] - children += open_buttons - - self.view.children = children - idle_add(self._update_size) - self._drag_in_progress = False - self.check_occlusion_state() - - def _update_size(self): - width, _ = self.view.get_preferred_width() - self.set_size_request(width, -1) - return False - - def get_clients(self): - try: - return json.loads(self.conn.send_command("j/clients").reply.decode()) - except json.JSONDecodeError: - return [] - - def get_focused(self): - try: - return json.loads( - self.conn.send_command("j/activewindow").reply.decode() - ).get("address", "") - except json.JSONDecodeError: - return "" - - def get_workspace(self): - try: - return json.loads( - self.conn.send_command("j/activeworkspace").reply.decode() - ).get("id", 0) - except json.JSONDecodeError: - return 0 - - def check_occlusion_state(self): - if ( - self.is_mouse_over_dock_area - or self._drag_in_progress - or self._prevent_occlusion - ): - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - if not self.always_occluded: - self.dock_full.remove_style_class("occluded") - return True - - if self.always_occluded: - if self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(False) - self.dock_full.add_style_class("occluded") - return True - - occlusion_region = ("bottom", self.effective_occlusion_size) - is_occluded_by_window = check_occlusion(occlusion_region) - is_empty = not self.view.get_children() - - if is_occluded_by_window or is_empty: - if self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(False) - self.dock_full.add_style_class("occluded") - else: - if not self.dock_revealer.get_reveal_child(): - self.dock_revealer.set_reveal_child(True) - self.dock_full.remove_style_class("occluded") - - return True - - def _find_drag_target(self, widget): - children = self.view.get_children() - while widget is not None and widget not in children: - widget = widget.get_parent() if hasattr(widget, "get_parent") else None - return widget - - def on_drag_data_get(self, widget, drag_context, data_obj, info, time): - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) - if target is not None: - index = self.view.get_children().index(target) - data_obj.set_text(str(index), -1) - - def on_drag_data_received(self, widget, drag_context, x, y, data_obj, info, time): - target = self._find_drag_target( - widget.get_parent() if isinstance(widget, Box) else widget - ) - if target is None: - return - try: - source_index = int(data_obj.get_text()) - except (TypeError, ValueError): - return - - children = self.view.get_children() - try: - target_index = children.index(target) - except ValueError: - return - - if source_index != target_index: - separator_index = -1 - for i, child_item_loop in enumerate(children): - if child_item_loop.get_name() == "dock-separator": - separator_index = i - break - cross_section_drag = separator_index != -1 and ( - (source_index < separator_index and target_index > separator_index) - or (source_index > separator_index and target_index < separator_index) - ) - - child_item_to_move = children.pop(source_index) - children.insert(target_index, child_item_to_move) - self.view.children = children - self.update_pinned_apps(skip_update=not cross_section_drag) - if cross_section_drag: - GLib.idle_add(self.update_dock) - - def on_drag_end(self, widget, drag_context): - if not self._drag_in_progress: - return - - def process_drag_end(): - display = Gdk.Display.get_default() - _, x, y, _ = display.get_pointer() - - # Get the widget's allocation to check if drag ended outside - alloc = self.view.get_allocation() - widget_x = alloc.x - widget_y = alloc.y - widget_width = alloc.width - widget_height = alloc.height - - # Check if pointer is outside the dock area - if not ( - widget_x <= x <= widget_x + widget_width - and widget_y <= y <= widget_y + widget_height - ): - app_id_dragged = widget.app_identifier - instances_dragged = widget.instances - - # Remove pinned app - app_index_dragged = -1 - for i, pinned_app_item in enumerate(self.pinned): - if isinstance(app_id_dragged, dict) and isinstance( - pinned_app_item, dict - ): - if app_id_dragged.get("name") == pinned_app_item.get("name"): - app_index_dragged = i - break - elif app_id_dragged == pinned_app_item: - app_index_dragged = i - break - - if app_index_dragged >= 0: - self.pinned.pop(app_index_dragged) - self.config["pinned_apps"] = self.pinned - self.update_pinned_apps_file() - self.update_dock() - elif instances_dragged: - address = instances_dragged[0].get("address") - if address: - exec_shell_command( - f"hyprctl dispatch focuswindow address:{address}" - ) - - self._drag_in_progress = False - self.check_occlusion_state() - - GLib.idle_add(process_drag_end) - - def check_config_change(self): - new_config = read_config() - new_always_occluded = data.DOCK_ALWAYS_OCCLUDED - if self.always_occluded != new_always_occluded: - self.always_occluded = new_always_occluded - self.check_occlusion_state() - - if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): - self.config = new_config - self.pinned = self.config.get("pinned_apps", []) - self.update_app_map() - self.update_dock() - return True - - def update_pinned_apps_file(self): - config_path = get_relative_path("../config/assets/dock.json") - try: - with open(config_path, "w") as file: - json.dump(self.config, file, indent=4) - return True - except Exception as e: - logging.error(f"Failed to write dock config: {e}") - return False - - def update_pinned_apps(self, skip_update=False): - pinned_children_data = [] - for child_widget in self.view.get_children(): - if child_widget.get_name() == "dock-separator": - break - if hasattr(child_widget, "app_identifier"): - if hasattr(child_widget, "desktop_app") and child_widget.desktop_app: - app = child_widget.desktop_app - app_data_obj = { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - } - pinned_children_data.append(app_data_obj) - else: - pinned_children_data.append(child_widget.app_identifier) - - self.config["pinned_apps"] = pinned_children_data - self.pinned = pinned_children_data - file_updated = self.update_pinned_apps_file() - if file_updated and not skip_update: - self.update_dock() - - @staticmethod - def notify_config_change(): - for dock_instance in Dock._instances: - GLib.idle_add(dock_instance.check_config_change_immediate) - - def check_config_change_immediate(self): - new_config = read_config() - - previous_always_occluded = self.always_occluded - self.always_occluded = data.DOCK_ALWAYS_OCCLUDED - - if previous_always_occluded != self.always_occluded: - self.check_occlusion_state() - - if new_config.get("pinned_apps", []) != self.config.get("pinned_apps", []): - self.config = new_config - self.pinned = self.config.get("pinned_apps", []) - self.update_app_map() - self.update_dock() - return False - - @staticmethod - def update_visibility(visible): - for dock in Dock._instances: - dock.set_visible(visible) - if visible: - GLib.idle_add(dock.check_occlusion_state) - else: - if ( - hasattr(dock, "dock_revealer") - and dock.dock_revealer.get_reveal_child() - ): - dock.dock_revealer.set_reveal_child(False) diff --git a/modules/launcher/plugins/applications.py b/modules/launcher/plugins/applications.py index d9463b5..10d2e22 100644 --- a/modules/launcher/plugins/applications.py +++ b/modules/launcher/plugins/applications.py @@ -5,9 +5,9 @@ import subprocess from fabric.utils import DesktopApp from fabric.utils.helpers import get_desktop_applications, get_relative_path -from modules.dock import Dock from modules.launcher.plugin_base import PluginBase from modules.launcher.result import Result +from utils.roam import modus_service class ApplicationsPlugin(PluginBase): @@ -24,48 +24,33 @@ class ApplicationsPlugin(PluginBase): def _pin_application(self, app): """Pin an application to the dock.""" - app_data = { - k: v - for k, v in { - "name": app.name, - "display_name": app.display_name, - "window_class": app.window_class, - "executable": app.executable, - "command_line": app.command_line, - "icon_name": app.icon_name, - }.items() - if v is not None - } - config_path = get_relative_path("../../../config/assets/dock.json") try: with open(config_path, "r") as file: - data = json.load(file) + pinned_apps = json.load(file) except (FileNotFoundError, json.JSONDecodeError): - data = {"pinned_apps": []} + pinned_apps = [] - already_pinned = False - for pinned_app in data.get("pinned_apps", []): - if ( - isinstance(pinned_app, dict) - and pinned_app.get("name") == app_data["name"] - ): - already_pinned = True - pinned_app.update(app_data) - break - elif isinstance(pinned_app, str) and pinned_app == app_data["name"]: - already_pinned = True - data["pinned_apps"].remove(pinned_app) - data["pinned_apps"].append(app_data) - break + # Handle legacy format (dict with "pinned_apps" key) and convert to new format (simple list) + if isinstance(pinned_apps, dict): + pinned_apps = pinned_apps.get("pinned_apps", []) - if not already_pinned: - data.setdefault("pinned_apps", []).append(app_data) + # Check if app is already pinned (by name/app_id) + app_id = app.name # Use app.name as the identifier + if app_id not in pinned_apps: + pinned_apps.append(app_id) - with open(config_path, "w") as file: - json.dump(data, file, indent=4) + # Save the updated list + with open(config_path, "w") as file: + json.dump(pinned_apps, file, indent=4) - Dock.notify_config_change() + # Notify dock about the change via modus_service + if modus_service: + try: + dock_apps_json = json.dumps(pinned_apps) + modus_service.dock_apps = dock_apps_json + except Exception as e: + print(f"Failed to notify dock about pinned app change: {e}") def query(self, query_string: str) -> List[Result]: """Search applications based on query.""" @@ -156,8 +141,6 @@ class ApplicationsPlugin(PluginBase): pattern = ".*".join(re.escape(char) for char in query) return bool(re.search(pattern, text, re.IGNORECASE)) - import subprocess - def _launch_application(self, app: DesktopApp): print("Original:", app.command_line) diff --git a/styles/dock.css b/styles/dock.css index 40554e1..ba54dbe 100644 --- a/styles/dock.css +++ b/styles/dock.css @@ -1,95 +1,39 @@ #dock { - background-color: alpha(#fff, 0.05); - padding: 8px; - margin: 8px 8px 5px 8px; + background-color: alpha(#fff, 0.07); + padding: 4px 4px; + margin: 4px 4px; + border: none; border-radius: 16px; - /* border: 2px solid var(--surface); */ - /* transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); */ -} - -#dock-app-overlay { -} -#dock-app-overlay label { - background-color: alpha(#000, 0.5); - font-size: 12px; - font-family: "SF Pro Rounded"; - margin-top: 20px; - min-width: 17px; - border-radius: 8px; -} -#dock.vertical { - margin: 8px 0 8px 8px; - border-radius: 20px 0 0 20px; -} - -#dock.vertical.left { - margin: 8px 8px 8px 0; - border-radius: 0 20px 20px 0; -} - -#dock-full { - /* transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); */ - opacity: 1; -} - -#dock-separator { - padding: 2px; - border-radius: 16px; - background-color: var(--surface-bright); + transition: all 0.25s cubic-bezier(0.5, 0.25, 0, 1); } #dock-app-button { - padding: 6px; - border-radius: 24px; - transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); - /* box-shadow: 0 0 4px alpha(var(--shadow), 0.5); */ - /* transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275); */ + border-radius: 16px; } -#dock-app-button.dock-icon-hover { - padding: 4px; - border-radius: 28px; - background-color: alpha(#fff, 0.15); - box-shadow: 0 4px 12px alpha(#000, 0.2); +#dock-separator { + min-width: 3px; + margin: 2px 0; + background-color: var(--surface-bright); + border-radius: 8px; } #dock-app-button:hover { - background-color: var(--surface-bright); + margin-top: -55px; + margin-bottom: 4px; + transition: all 0.15s ease-out; } -#dock-app-button:hover.instance { - background-color: var(--outline); +#dock-app-button.semi_hovered { + margin-top: -20px; + margin-bottom: 2px; + transition: all 0.15s ease-out; } -#dock-app-button:active, -#dock-app-button:active.instance { - background-color: var(--primary); -} - -#dock-app-button.instance { - border-radius: 12px; -} - -#dock-app-dot { - background-color: var(--primary); - border-radius: 50%; - margin-top: 4px; - min-width: 4px; - min-height: 4px; -} - -#dock-corner-left { - margin: 0 -8px 0 0; -} - -#dock-corner-right { - margin: 0 0 0 -8px; -} - -#dock-corner-top { - margin: 0 0 -8px 0; -} - -#dock-corner-bottom { - margin: -8px 0 0 0; +#dock-app-overlay label { + background-color: alpha(#000, 0.5); + font-size: 12px; + margin-top: 20px; + min-width: 15px; + border-radius: 8px; } diff --git a/utils/exml.py b/utils/exml.py deleted file mode 100644 index 2c292ab..0000000 --- a/utils/exml.py +++ /dev/null @@ -1,98 +0,0 @@ -from xml import etree -from xml.etree import ElementTree -from fabric.utils import get_relative_path -import json - - -def parse(xml): - with open(xml, "rb") as f: - tree = ElementTree.parse(f) - root = tree.getroot() - return root - - -def dxml(root, depth=0): - val = [] - for child in root: - attrstring = {} - additional = {} - if len(child.attrib) == 0: - if child.tag == "BoxV": - additional["orientation"] = "vertical" - elif child.tag == "BoxH": - additional["orientation"] = "horizontal" - for k, v in child.attrib.items(): - if v == None: - continue - if child.tag == "Ref": - continue - if child.tag == "BoxV": - additional["orientation"] = "vertical" - elif child.tag == "BoxH": - additional["orientation"] = "horizontal" - if k == "path": - # Handle path resolution more robustly - import os - - # Get the directory where the XML file is located - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Go up to project root and then to the specified path - project_root = os.path.dirname(current_dir) # Go up from utils/ - full_path = os.path.join(project_root, v) - v = full_path - k = "svg_file" - if v == "true" or v == "false": - v = v == "true" - attrstring[k] = v - for k, v in additional.items(): - attrstring[k] = v - tag = child.tag - if tag in ["BoxV", "BoxH"]: - tag = "Box" - if tag == "Ref": - val.append( - { - "name": tag, - "text": child.text, - } - ) - continue - val.append( - { - "name": tag, - "attributes": attrstring, - "children": dxml(child, depth + 1), - } - ) - return val - - -def parse_child(tags, refs, child, parent): - for ch in child: - ch = ch.copy() - name = ch.get("name", None) - attrs = ch.get("attributes", None) - if attrs: - for attr in attrs: - if str(attrs[attr]).isnumeric(): - attrs[attr] = int(attrs[attr]) - children = ch.get("children", None) - if children: - ch.pop("children") - if name == "Ref": - text = ch.get("text", None) - v = refs[text] - parent.children = [*parent.children, v] - else: - v = tags[name](**attrs) - if children: - v.children = parse_child(tags, refs, children, v) - parent.children = [*parent.children, v] - return parent.children - - -def exml(file: str, tags: dict, refs: dict, root): - odi = dxml(parse(file)) - di = root(children=[]) - parse_child(tags, refs, odi, di) - return di diff --git a/utils/functions.py b/utils/functions.py new file mode 100644 index 0000000..fc6ea3c --- /dev/null +++ b/utils/functions.py @@ -0,0 +1,51 @@ +import json +import os +import threading +from typing import Dict, List, Optional + +from loguru import logger + +# Threading helper functions + + +def thread(target, *args, **kwargs) -> threading.Thread: + """ + Simply run the given function in a thread. + The provided args and kwargs will be passed to the function. + """ + th = threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True) + th.start() + return th + + +def run_in_thread(func): + """ + Decorator to run the decorated function in a thread. + """ + + def wrapper(*args, **kwargs): + return thread(func, *args, **kwargs) + + return wrapper + + +@run_in_thread +def write_json_file(data: Dict, path: str): + try: + with open(path, "w") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + except Exception as e: + logger.warning(f"Failed to write json: {e}") + + +def read_json_file(file_path: str) -> Optional[List]: + if not os.path.exists(file_path): + logger.error(f"JSON file {file_path} does not exist.") + return None + + with open(file_path, "r") as file: + try: + return json.load(file) + except json.JSONDecodeError as e: + logger.error(f"Failed to read JSON file {file_path}: {e}") + return None diff --git a/utils/icons.py b/utils/icons.py deleted file mode 100644 index 2a1059f..0000000 --- a/utils/icons.py +++ /dev/null @@ -1,234 +0,0 @@ -# Parameters -font_family: str = "tabler-icons" -font_weight: str = "normal" - -span: str = f"" - -# Panels -apps: str = "" -dashboard: str = "" -chat: str = "" -windows: str = "" - -# Bar -colorpicker: str = "" -media: str = "" - -# Toolbox - -toolbox: str = "" -ssfull: str = "" -ssregion: str = "" -sswindow: str = "" -screenshots: str = "" -screenrecord: str = "" -recordings: str = "" -ocr: str = "ﳃ" -gamemode: str = "" -gamemode_off: str = "" -close: str = "" - -# Circles -temp: str = "" -disk: str = "" -battery: str = "" -memory: str = "流" -cpu: str = "" -gpu: str = "" -swap: str = "" - - -# AIchat -reload: str = "" -detach: str = "" - -# Wallpapers -add: str = "" -sort: str = "" -circle: str = "" - -# Chevrons -chevron_up: str = "" -chevron_down: str = "" -chevron_left: str = "" -chevron_right: str = "" - -# Power -lock: str = "" -suspend: str = "" -logout: str = "" -reboot: str = "" -shutdown: str = "" - -# Power Manager -power_saving: str = "" -power_balanced: str = "勺" -power_performance: str = "" -charging: str = "" -discharging: str = "" -alert: str = "" -bat_charging: str = "" -bat_discharging: str = "" -bat_low: str = "=" -bat_full: str = "" - - -# Applets - -# Applets -wifi_0: str = "" -wifi_1: str = "" -wifi_2: str = "" -wifi_3: str = "" -world: str = "" -world_off: str = "" -bluetooth: str = "" -night: str = "" -coffee: str = "" -notifications: str = "" -notifications_dots: str = "" - -wifi_off: str = "" -bluetooth_off: str = "" -night_off: str = "" -notifications_off: str = "" - -notifications_clear: str = "" - -download: str = "" -upload: str = "" - -# Bluetooth -bluetooth_connected: str = "" -bluetooth_disconnected: str = "" - -# Player -pause: str = "" -play: str = "" -stop: str = "" -skip_back: str = "" -skip_forward: str = "" -prev: str = "" -next: str = "" -shuffle: str = "" -repeat: str = "" -music: str = "" -rewind_backward_5: str = "謹" -rewind_forward_5: str = "難" - -next_fill: str = chr(0xF04AD) -previous_fill: str = chr(0xF04AE) - -# Volume -vol_off: str = "" -vol_mute: str = "" -vol_medium: str = "" -vol_high: str = "" - -mic: str = "" -mic_mute: str = "" - -speaker: str = "𐁅" -headphones: str = "屮" -mic_filled: str = "️" - -# Overview -circle_plus: str = "" - -# Pins -paperclip: str = "" - -# Clipboard Manager -clipboard: str = "" -clip_text: str = "" - -# Confirm -accept: str = "" -cancel: str = "" -trash: str = "" - -# Config -config: str = "" - -# Icons -firefox: str = "" -chromium: str = "" -spotify: str = "ﺆ" -disc: str = "𐀾" -disc_off: str = "" - -# Brightness -brightness_low: str = "" -brightness_medium: str = "" -brightness_high: str = "" - -brightness: str = "" - -# Dashboard -widgets: str = "" -pins: str = "" -kanban: str = "" -wallpapers: str = "" -sparkles: str = "" - -# Misc -dot: str = "" -palette: str = "" -cloud_off: str = "" -loader: str = "" -radar: str = "" -emoji: str = "" -keyboard: str = "" -terminal: str = "" -timer_off: str = "" -timer_on: str = "" -spy: str = "" -calculator: str = "" -file: str = "" -calendar: str = "" -connect: str = "" -disconnect: str = "ﴖ" -auth: str = "" -bookmark: str = "" -star: str = "" -process: str = "" - - -music: str = "" -# otp -check: str = "" -bulb: str = "" -key: str = "" -magnifier: str = "" -plus: str = "" -info: str = "" - -# Dice -dice_1: str = "" -dice_2: str = "" -dice_3: str = "" -dice_4: str = "" -dice_5: str = "" -dice_6: str = "" - -# search engine -reddit: str = "" -youtube: str = "" -duckduckgo: str = "" -github: str = "" -stackoverflow: str = "" -google: str = "" -linkedin: str = "" -wikipedia: str = "啕" - -exceptions: list[str] = ["font_family", "font_weight", "span"] - - -def apply_span() -> None: - global_dict = globals() - for key in global_dict: - if key not in exceptions and not key.startswith("__"): - global_dict[key] = f"{span}{global_dict[key]}" - - -apply_span() diff --git a/utils/monitors.py b/utils/monitors.py new file mode 100644 index 0000000..01b706e --- /dev/null +++ b/utils/monitors.py @@ -0,0 +1,60 @@ +import json +import warnings +from typing import Dict +import time +from fabric.hyprland import Hyprland +from gi.repository import Gdk +from functools import lru_cache + +warnings.filterwarnings("ignore", category=DeprecationWarning) + + +def ttl_lru_cache(seconds_to_live: int, maxsize: int = 128): + def wrapper(func): + @lru_cache(maxsize) + def inner(__ttl, *args, **kwargs): + return func(*args, **kwargs) + + return lambda *args, **kwargs: inner( + time.time() // seconds_to_live, *args, **kwargs + ) + + return wrapper + + +class HyprlandWithMonitors(Hyprland): + """A Hyprland class with additional monitor common.""" + + instance = None + + @staticmethod + def get_default(): + if HyprlandWithMonitors.instance is None: + HyprlandWithMonitors.instance = HyprlandWithMonitors() + + return HyprlandWithMonitors.instance + + def __init__(self, commands_only: bool = False, **kwargs): + self.display: Gdk.Display = Gdk.Display.get_default() + super().__init__(commands_only, **kwargs) + + @ttl_lru_cache(100, 5) + def get_all_monitors(self) -> Dict: + monitors = json.loads(self.send_command("j/monitors").reply) + return {monitor["id"]: monitor["name"] for monitor in monitors} + + def get_gdk_monitor_id_from_name(self, plug_name: str) -> int | None: + for i in range(self.display.get_n_monitors()): + if self.display.get_default_screen().get_monitor_plug_name(i) == plug_name: + return i + return None + + def get_gdk_monitor_id(self, hyprland_id: int) -> int | None: + monitors = self.get_all_monitors() + if hyprland_id in monitors: + return self.get_gdk_monitor_id_from_name(monitors[hyprland_id]) + return None + + def get_current_gdk_monitor_id(self) -> int | None: + active_workspace = json.loads(self.send_command("j/activeworkspace").reply) + return self.get_gdk_monitor_id_from_name(active_workspace["monitor"]) diff --git a/widgets/popup_window.py b/widgets/popup_window.py index cff0e2f..d6d600c 100644 --- a/widgets/popup_window.py +++ b/widgets/popup_window.py @@ -1,26 +1,33 @@ +import contextlib import gi # type: ignore from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore from widgets.wayland import WaylandWindow +from utils.monitors import HyprlandWithMonitors gi.require_version("GtkLayerShell", "0.1") class PopupWindow(WaylandWindow): + """A simple popover window that can point to a widget.""" + def __init__( self, parent: WaylandWindow, pointing_to: Gtk.Widget | None = None, margin: tuple[int, ...] | str = "0px 0px 0px 0px", + enable_boundary_checking: bool = True, **kwargs, ): super().__init__(**kwargs) self.exclusivity = "none" - + self._is_centered = False self._parent = parent self._pointing_widget = pointing_to + self._hyprland = HyprlandWithMonitors() self._base_margin = self.extract_margin(margin) self.margin = self._base_margin.values() + self._enable_boundary_checking = enable_boundary_checking self.connect("notify::visible", self.do_update_handlers) @@ -36,10 +43,8 @@ class PopupWindow(WaylandWindow): def set_pointing_to(self, widget: Gtk.Widget | None): if self._pointing_widget: - try: + with contextlib.suppress(Exception): self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) - except Exception: - pass self._pointing_widget = widget return self.do_update_handlers() @@ -68,12 +73,15 @@ class PopupWindow(WaylandWindow): parent_anchor = self._parent.anchor if len(parent_anchor) != 3: + self.anchor = "left bottom" + self._is_centered = True return move_axe if ( GtkLayerShell.Edge.LEFT in parent_anchor and GtkLayerShell.Edge.RIGHT in parent_anchor ): + # horizontal -> move on x-axies move_axe = "x" if GtkLayerShell.Edge.TOP in parent_anchor: self.anchor = "left top" @@ -83,12 +91,14 @@ class PopupWindow(WaylandWindow): GtkLayerShell.Edge.TOP in parent_anchor and GtkLayerShell.Edge.BOTTOM in parent_anchor ): + # vertical -> move on y-axies move_axe = "y" if GtkLayerShell.Edge.RIGHT in parent_anchor: self.anchor = "top right" else: self.anchor = "top left" + self._is_centered = False return move_axe def do_reposition(self, move_axe: str): @@ -98,48 +108,84 @@ class PopupWindow(WaylandWindow): height = self.get_allocated_height() width = self.get_allocated_width() - screen = Gdk.Screen.get_default() - screen_width = screen.get_width() + # Get monitor geometry for boundary checking + current_monitor_id = self._hyprland.get_current_gdk_monitor_id() + if current_monitor_id is not None: + monitor = self._hyprland.display.get_monitor(current_monitor_id) + monitor_geometry = monitor.get_geometry() + monitor_x, monitor_y = monitor_geometry.x, monitor_geometry.y + monitor_width, monitor_height = monitor_geometry.width, monitor_geometry.height + else: + # Fallback to default screen + screen = Gdk.Screen.get_default() + monitor_x, monitor_y = 0, 0 + monitor_width, monitor_height = screen.get_width(), screen.get_height() if self._pointing_widget: coords = self.get_coords_for_widget(self._pointing_widget) - if coords[0] + width > screen_width: - coords_centered = ( - round( - coords[0] - - (width - self._pointing_widget.get_allocated_width()) - ), - round(coords[1] + self._pointing_widget.get_allocated_height() / 2), - ) - else: - coords_centered = ( - round(coords[0]), - round(coords[1] + self._pointing_widget.get_allocated_height() / 2), - ) + coords_centered = ( + round(coords[0] + self._pointing_widget.get_allocated_width() / 2), + round(coords[1] + self._pointing_widget.get_allocated_height() / 2), + ) else: coords_centered = ( round(self._parent.get_allocated_width() / 2), round(self._parent.get_allocated_height() / 2), ) + if self._is_centered: + # Calculate centered position with boundary checking + centered_x = ( + (monitor_width / 2 - self._parent.get_allocated_width() / 2) + - width / 2 + ) + coords_centered[0] + + # Apply boundary checking only if enabled + if self._enable_boundary_checking: + if centered_x < monitor_x: + centered_x = monitor_x + elif centered_x + width > monitor_x + monitor_width: + centered_x = monitor_x + monitor_width - width + + self.margin = tuple( + a + b + for a, b in zip( + (0, 0, 0, centered_x), + self._base_margin.values(), + ) + ) + return + + # Calculate position with boundary checking + if move_axe == "x": + # Horizontal positioning + calculated_x = round((parent_x_margin + coords_centered[0]) - (width / 2)) + + # Apply boundary checking only if enabled + if self._enable_boundary_checking: + if calculated_x < monitor_x: + calculated_x = monitor_x + elif calculated_x + width > monitor_x + monitor_width: + calculated_x = monitor_x + monitor_width - width + + margin_values = (0, 0, 0, calculated_x) + else: + # Vertical positioning + calculated_y = round((parent_y_margin + coords_centered[1]) - (height / 2)) + + # Apply boundary checking only if enabled + if self._enable_boundary_checking: + if calculated_y < monitor_y: + calculated_y = monitor_y + elif calculated_y + height > monitor_y + monitor_height: + calculated_y = monitor_y + monitor_height - height + + margin_values = (calculated_y, 0, 0, 0) + self.margin = tuple( a + b for a, b in zip( - ( - ( - 0, - 0, - 0, - round((parent_x_margin + coords_centered[0])), - ) - if move_axe == "x" - else ( - round((parent_y_margin + coords_centered[1]) - (height / 2)), - 0, - 0, - 0, - ) - ), + margin_values, self._base_margin.values(), ) )