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.brightness_scale]
-
-
-
-
- [self.volume_scale]
-
-
-
- [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(),
)
)