from gi.repository import GLib, Gtk import json import os import re import subprocess from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.eventbox import EventBox from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay from fabric.widgets.revealer import Revealer from fabric.widgets.separator import Separator from widgets.wayland import WaylandWindow as Window from loguru import logger from utils.icon_resolver import IconResolver from utils.occlusion import check_occlusion from fabric.utils.helpers import get_relative_path, get_desktop_applications from services.modus import modus_service from utils.functions import read_json_file, write_json_file import config.data as data # Pinned apps file PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json") class AppBar(Box): def __init__(self, parent: Window): self.client_buttons = {} # For running app instances self.pinned_buttons = {} # For pinned apps self._parent = parent # Set orientation based on dock position orientation = "vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal" super().__init__( spacing=10, name="dock", orientation=orientation, children=[], ) self.icon_resolver = IconResolver() self._hyprland_connection = modus_service._hyprland_connection # Initialize GTK menu self.menu = Gtk.Menu() self.pinned_apps = read_json_file(PINNED_APPS_FILE) or [] self.pinned_apps_container = Box(name="pinned-apps-container") self.add(self.pinned_apps_container) self.separator = Separator(name="dock-separator") self.add(self.separator) self.running_apps_container = Box(name="running-apps-container") self.add(self.running_apps_container) self._populate_pinned_apps() self.setup_app_monitoring() def setup_app_monitoring(self): def update_running_apps(): try: self.update_dock_apps() except Exception as e: logger.error(f"[AppBar] Error updating apps: {e}") return True GLib.timeout_add(1000, update_running_apps) GLib.idle_add(self.update_dock_apps) def _populate_pinned_apps(self): for child in self.pinned_apps_container.get_children(): self.pinned_apps_container.remove(child) self.pinned_buttons = {} try: desktop_apps = get_desktop_applications(include_hidden=False) except Exception: desktop_apps = [] for app_data in self.pinned_apps: self._create_pinned_button(app_data, desktop_apps) def _create_pinned_button(self, app_data, desktop_apps): if isinstance(app_data, dict): app_identifier = app_data.get("name", "") or app_data.get( "window_class", "" ) display_name = app_data.get("display_name", app_identifier) app = self._find_desktop_app_from_data(app_data, desktop_apps) if app: icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) else: icon_name = app_data.get("window_class", "") or app_data.get("name", "") icon_pixbuf = self.icon_resolver.get_icon_pixbuf( icon_name, data.DOCK_ICON_SIZE ) else: app_identifier = app_data app = self._find_desktop_app_by_id(app_data, desktop_apps) if not app: return display_name = app.display_name or app.name icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE) pinned_image = Image(pixbuf=icon_pixbuf) icon_container = Box( name="dock-icon", orientation="vertical", h_align="center", children=[pinned_image], ) overlay_container = Overlay(name="dock-app-overlay", child=icon_container) pinned_button = Button( name="dock-app-button", child=overlay_container, tooltip_text=display_name, on_button_press_event=lambda _, event: self._handle_pinned_app_click( event, app_data ), ) self.pinned_buttons[app_identifier] = pinned_button self.pinned_apps_container.add(pinned_button) def _find_desktop_app_from_data(self, app_data: dict, desktop_apps): for app in desktop_apps: if ( ( app_data.get("name") and app.name and app.name.lower() == app_data["name"].lower() ) or ( app_data.get("window_class") and hasattr(app, "window_class") and app.window_class and app.window_class.lower() == app_data["window_class"].lower() ) or ( app_data.get("executable") and app.executable and ( app.executable.lower() == app_data["executable"].lower() or os.path.basename(app.executable).lower() == os.path.basename(app_data["executable"]).lower() ) ) ): return app return None def _find_desktop_app_by_id(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 os.path.basename(app.executable).lower() == app_id.lower() ) ) ): return app return None def show_menu(self, app_id: str, client=None, instance_address=None): for item in self.menu.get_children(): self.menu.remove(item) item.destroy() if client or instance_address: close_item = Gtk.MenuItem(label="Close") if instance_address: close_item.connect( "activate", lambda *_: self._close_running_app(instance_address) ) self.menu.add(close_item) if app_id: separator = Gtk.SeparatorMenuItem() self.menu.add(separator) if app_id: is_pinned = self._is_app_pinned(app_id) pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin") if is_pinned: 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, instance_address): try: self._hyprland_connection.send_command( f"dispatch closewindow address:{instance_address}" ) except Exception as e: logger.error(f"[AppBar] Error closing window: {e}") def _handle_pinned_app_click(self, event, app_data): if event.button == 1: # Left click - launch app self._launch_app_data(app_data) elif event.button == 2: # Middle click - unpin app app_identifier = self._get_app_identifier(app_data) self._unpin_app(app_identifier) elif event.button == 3: # Right click - show context menu app_identifier = self._get_app_identifier(app_data) self.show_menu(app_identifier) self.menu.popup_at_pointer(event) def _get_app_identifier(self, app_data): if isinstance(app_data, dict): return app_data.get("name", "") or app_data.get("window_class", "") return app_data def _launch_app_data(self, app_data): try: desktop_apps = get_desktop_applications(include_hidden=False) if isinstance(app_data, dict): app = self._find_desktop_app_from_data(app_data, desktop_apps) if app: self._launch_app(app) else: self._launch_app_from_data(app_data) else: app = self._find_desktop_app_by_id(app_data, desktop_apps) if app: self._launch_app(app) except Exception as e: logger.error(f"[AppBar] Failed to launch app: {e}") def _launch_app(self, app): try: 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) except Exception: try: app.launch() except Exception as fallback_error: logger.error(f"[AppBar] Failed to launch app: {fallback_error}") def _launch_app_from_data(self, app_data): try: command_line = app_data.get("command_line", "") if command_line: cleaned_command = re.sub(r"%\w+", "", command_line).strip() final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'" subprocess.Popen(final_command, shell=True) elif app_data.get("executable"): final_command = ( f"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'" ) subprocess.Popen(final_command, shell=True) else: logger.error( f"[AppBar] No command or executable found for app: {app_data}" ) except Exception as e: logger.error(f"[AppBar] Failed to launch app from data: {e}") def _pin_app(self, app_class: str): if self._is_app_pinned(app_class): return False try: desktop_apps = get_desktop_applications(include_hidden=False) app = self._find_desktop_app_by_id(app_class, desktop_apps) if app: app_data = { "name": app.name, "display_name": app.display_name or app.name, "window_class": getattr(app, "window_class", None) or app_class, "executable": app.executable, "command_line": app.command_line, } else: # Fallback: create minimal app data app_data = { "name": app_class, "display_name": app_class, "window_class": app_class, "executable": app_class, "command_line": app_class, } self.pinned_apps.append(app_data) except Exception: self.pinned_apps.append(app_class) write_json_file(self.pinned_apps, PINNED_APPS_FILE) self._populate_pinned_apps() return True def _unpin_app(self, app_identifier: str): apps_to_remove = [] for i, pinned_app in enumerate(self.pinned_apps): if self._matches_app_identifier(pinned_app, app_identifier): apps_to_remove.append(i) for i in reversed(apps_to_remove): self.pinned_apps.pop(i) if apps_to_remove: write_json_file(self.pinned_apps, PINNED_APPS_FILE) self._populate_pinned_apps() return True return False def _matches_app_identifier(self, pinned_app, app_identifier): if not app_identifier: return False if isinstance(pinned_app, dict): window_class = pinned_app.get("window_class") or "" name = pinned_app.get("name") or "" return ( window_class.lower() == app_identifier.lower() or name.lower() == app_identifier.lower() ) return ( isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower() ) def get_clients(self): try: clients_data = self._hyprland_connection.send_command("j/clients").reply if not clients_data: return [] return json.loads(clients_data.decode("utf-8")) except Exception as e: logger.error(f"[AppBar] Error getting clients: {e}") return [] def get_focused_window(self): try: active_data = self._hyprland_connection.send_command("j/activewindow").reply if not active_data: return None return json.loads(active_data.decode("utf-8")) except Exception as e: logger.error(f"[AppBar] Error getting focused window: {e}") return None def update_dock_apps(self): clients = self.get_clients() focused_window = self.get_focused_window() focused_address = focused_window.get("address", "") if focused_window else "" current_instance_ids = set() for client in clients: if client.get("hidden", False): continue instance_address = client.get("address", "") if not instance_address: continue current_instance_ids.add(instance_address) app_class = client.get("class", "") or client.get("title", "unknown-app") if instance_address not in self.client_buttons: self.create_instance_button(instance_address, client, app_class) else: self.update_instance_button(instance_address, client, app_class) button = self.client_buttons[instance_address] if instance_address == focused_address: button.add_style_class("active") else: button.remove_style_class("active") self._update_pinned_apps_state(clients) self._update_separator_visibility() self._cleanup_removed_instances(current_instance_ids) def _update_pinned_apps_state(self, clients): running_app_classes = { client.get("class", "").lower() or client.get("title", "").lower() for client in clients if not client.get("hidden", False) and (client.get("class") or client.get("title")) } for app_identifier, button in self.pinned_buttons.items(): if app_identifier.lower() in running_app_classes: button.add_style_class("instance") else: button.remove_style_class("instance") def _cleanup_removed_instances(self, current_instance_ids): buttons_to_remove = [ instance_id for instance_id in self.client_buttons.keys() if instance_id not in current_instance_ids ] for instance_id in buttons_to_remove: button = self.client_buttons.pop(instance_id) self.running_apps_container.remove(button) def create_instance_button(self, instance_address, client, app_class): client_image = Image() try: desktop_apps = get_desktop_applications(include_hidden=False) desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps) if desktop_app: pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE) else: pixbuf = self.icon_resolver.get_icon_pixbuf(app_class, data.DOCK_ICON_SIZE) client_image.set_from_pixbuf(pixbuf) except Exception as e: logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}") workspace_id = self._get_workspace_id(client) workspace_label = None if workspace_id is not None: workspace_label = Label( label=str(workspace_id), name="workspace-indicator", h_align="end", v_align="end", ) image_overlay = Overlay(name="dock-image-overlay", child=client_image) if workspace_label: image_overlay.add_overlay(workspace_label) icon_container = Box( name="dock-icon", orientation="vertical", h_align="center", children=[image_overlay], ) overlay_container = Overlay(name="dock-app-overlay", child=icon_container) tooltip_text = client.get("title", app_class) if tooltip_text != app_class: tooltip_text = f"{app_class}: {tooltip_text}" client_button = Button( name="dock-app-button", child=overlay_container, tooltip_text=tooltip_text, on_button_press_event=lambda widget, event: self.handle_instance_click( widget, event ), ) client_button.instance_address = instance_address client_button.client_data = client client_button.app_class = app_class client_button.workspace_label = workspace_label self.client_buttons[instance_address] = client_button self.running_apps_container.add(client_button) def _get_workspace_id(self, client): workspace_data = client.get("workspace", {}) if isinstance(workspace_data, dict): return workspace_data.get("id") elif isinstance(workspace_data, (int, str)): return workspace_data return None def update_instance_button(self, instance_address, client, app_class): if instance_address not in self.client_buttons: return button = self.client_buttons[instance_address] button.client_data = client button.app_class = app_class tooltip_text = client.get("title", app_class) if tooltip_text != app_class: tooltip_text = f"{app_class}: {tooltip_text}" button.set_tooltip_text(tooltip_text) workspace_id = self._get_workspace_id(client) existing_label = getattr(button, "workspace_label", None) overlay_container = button.get_child() if isinstance(overlay_container, Overlay): icon_container = overlay_container.get_child() if hasattr(icon_container, "get_children"): children = icon_container.get_children() if children: image_overlay = children[0] if isinstance(image_overlay, Overlay): # Remove existing workspace label if existing_label and existing_label.get_parent(): image_overlay.remove_overlay(existing_label) # Add new workspace label if needed if workspace_id is not None: new_label = Label( label=str(workspace_id), name="workspace-indicator", h_align="end", v_align="end", ) image_overlay.add_overlay(new_label) button.workspace_label = new_label else: button.workspace_label = None def handle_instance_click(self, button_widget, event): instance_address = getattr(button_widget, "instance_address", None) app_class = getattr(button_widget, "app_class", None) if event.button == 1: # Left click - focus window if instance_address: try: self._hyprland_connection.send_command( f"dispatch focuswindow address:{instance_address}" ) except Exception as e: logger.error(f"[AppBar] Error focusing window: {e}") elif event.button == 2: # Middle click - pin/unpin app if app_class and not self._is_app_pinned(app_class): self._pin_app(app_class) elif event.button == 3: # Right click - context menu if app_class: self.show_menu(app_class, instance_address=instance_address) self.menu.popup_at_pointer(event) def _is_app_pinned(self, app_class: str) -> bool: return any( self._matches_app_identifier(pinned_app, app_class) for pinned_app in self.pinned_apps ) def _update_separator_visibility(self): has_pinned_apps = len(self.pinned_buttons) > 0 has_running_apps = len(self.client_buttons) > 0 self.separator.set_visible(has_pinned_apps and has_running_apps) class Dock(Window): def __init__(self): if not data.DOCK_ENABLED: anchor = self._get_anchor_from_position() super().__init__(layer="top", anchor=anchor) self.children = Box() # Empty dock if disabled return anchor = self._get_anchor_from_position() super().__init__(layer="top", anchor=anchor) self.app_bar = AppBar(self) transition_type = self._get_transition_type() self.revealer = Revealer( child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"), transition_duration=500, transition_type=transition_type, ) 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(), ) self.revealer.set_reveal_child(True) 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() 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 def _get_anchor_from_position(self): if data.DOCK_POSITION == "Left": return "left center" elif data.DOCK_POSITION == "Right": return "right center" else: # Bottom (default) return "bottom center" def _get_transition_type(self): if data.DOCK_POSITION == "Left": return "slide-right" elif data.DOCK_POSITION == "Right": return "slide-left" else: # Bottom (default) return "slide-up" def _get_occlusion_position(self): if data.DOCK_POSITION == "Left": return ("left", self.dock_height) elif data.DOCK_POSITION == "Right": return ("right", self.dock_height) else: # Bottom (default) return ("bottom", self.dock_height) def setup_occlusion_monitoring(self): def check_dock_occlusion(): try: if data.DOCK_ALWAYS_OCCLUDED: is_occluded = True else: occlusion_position = self._get_occlusion_position() is_occluded = check_occlusion(occlusion_position) if ( is_occluded and not self.is_hovered and self.revealer.get_reveal_child() ): self.revealer.set_reveal_child(False) elif not is_occluded and not self.revealer.get_reveal_child(): self.revealer.set_reveal_child(True) except Exception as e: logger.error(f"[Dock] Occlusion check error: {e}") return True GLib.timeout_add(250, check_dock_occlusion)