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) 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_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)