import json import logging import cairo 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.image import Image 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): desktop_app = self.find_app(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) return button 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: focused = self.get_focused() idx = next( (i for i, inst in enumerate(instances) if inst["address"] == focused), -1, ) next_inst = instances[(idx + 1) % len(instances)] exec_shell_command( f"hyprctl dispatch focuswindow address:{next_inst['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() 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)) pinned_buttons.append(self.create_button(app_data_item, instances)) 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 open_buttons.append(self.create_button(identifier, instances)) 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)