Files
Modus/modules/dock.py
T
sankalp cdc82c4c7f feat(modules): improve application searchmatching logic
- New Features
  - Expand search terms to handle reverse DNS and decorated names, increasing match coverage
  - Introduce priority‑based matching (exact window_class, name, executable) before falling back to fuzzier containment
  - Early exit when no search terms are available

- Enhancements
  - Refactor search term collection and normalization for better readability and performance
  - Add handling for common wrapper executables to prevent false positives
  - Implement fuzzy containment matching for partial name matches

- Bug Fixes
  - Fix return logic when search terms are empty to avoid unintended None returns
  - Correct order of priority checks to prioritize exact matches over fuzzy matches
2026-04-09 23:30:39 +05:45

849 lines
31 KiB
Python

import json
import os
import random
import re
import subprocess
from fabric.utils import get_desktop_applications, get_relative_path, logger, GLib, Gtk
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.eventbox import EventBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.overlay import Overlay
from fabric.widgets.revealer import Revealer
import config.data as data
from services.modus import modus_service
from utils.functions import (
read_json_file,
write_json_file,
is_special_workspace_id,
clear_children,
)
from utils.icon_resolver import IconResolver
from utils.occlusion import check_occlusion
from fabric.widgets.wayland import WaylandWindow as Window
# Pinned apps file
PINNED_APPS_FILE = get_relative_path("../config/assets/dock.json")
class AppBar(Box):
def __init__(self, parent: Window):
self.client_buttons = {} # For running app instances
self.pinned_buttons = {} # For pinned apps
# Position tracking for hover effects
self.running_items_pos = []
self.pinned_items_pos = []
self._parent = parent
# Set orientation based on dock position
orientation = (
"vertical" if data.DOCK_POSITION in ["Left", "Right"] else "horizontal"
)
super().__init__(
spacing=0,
name="dock",
orientation=orientation,
children=[],
)
self.icon_resolver = IconResolver()
self._hyprland_connection = modus_service._hyprland_connection
# Initialize GTK menu
self.menu = Gtk.Menu()
self.pinned_apps = read_json_file(PINNED_APPS_FILE) or []
self.pinned_apps_container = Box()
self.add(self.pinned_apps_container)
self.separator = Box(
v_align="center", style_classes=["hidden", "dock_separator"]
)
self.add(self.separator)
self.running_apps_container = Box(name="dock_container")
self.add(self.running_apps_container)
self._populate_pinned_apps()
self.setup_app_monitoring()
def setup_app_monitoring(self):
def update_running_apps():
try:
self.update_dock_apps()
except Exception as e:
logger.error(f"[AppBar] Error updating apps: {e}")
return True
GLib.timeout_add(250, update_running_apps)
GLib.idle_add(self.update_dock_apps)
def _populate_pinned_apps(self):
clear_children(self.pinned_apps_container)
self.pinned_buttons = {}
self.pinned_items_pos = []
try:
desktop_apps = get_desktop_applications(include_hidden=False)
except Exception:
desktop_apps = []
for app_data in self.pinned_apps:
self._create_pinned_button(app_data, desktop_apps)
# Add trash icon at the end of pinned apps
self._create_trash_button()
def _create_pinned_button(self, app_data, desktop_apps):
app = self._find_desktop_app(app_data, desktop_apps)
app_identifier = self._get_app_identifier(app_data)
display_name = (
app.display_name
if app
else (
app_data.get("display_name", app_identifier)
if isinstance(app_data, dict)
else app_identifier
)
)
icon_pixbuf = self._get_app_icon(app_data, app)
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[Image(name="dock_item_icon", pixbuf=icon_pixbuf)],
)
pinned_button = Button(
name="dock_item",
child=main_container,
tooltip_text=display_name,
on_button_press_event=lambda _, event: self._handle_pinned_app_click(
event, app_data
),
on_enter_notify_event=lambda *_: self._set_item_hover_state(
pinned_button, True, pinned=True
),
on_leave_notify_event=lambda *_: self._set_item_hover_state(
pinned_button, False, pinned=True
),
)
pinned_button.add_style_class("shown")
self.pinned_buttons[app_identifier] = pinned_button
self.pinned_apps_container.add(pinned_button)
self.pinned_items_pos.append(pinned_button)
def _get_app_icon(self, app_data, app=None):
if app:
return app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
icon_name = ""
if isinstance(app_data, dict):
icon_name = app_data.get("window_class") or app_data.get("name")
elif isinstance(app_data, str):
icon_name = app_data
return self.icon_resolver.get_icon_pixbuf(
icon_name or "application-x-executable", data.DOCK_ICON_SIZE
)
def _create_trash_button(self):
"""Create a trash button that opens the trash in file manager"""
trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
"user-trash", data.DOCK_ICON_SIZE
)
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[Image(name="dock_item_icon", pixbuf=trash_icon_pixbuf)],
)
trash_button = Button(
name="dock_item",
child=main_container,
tooltip_text="Trash",
on_button_press_event=lambda _, event: self._handle_trash_click(event),
on_enter_notify_event=lambda *_: self._set_item_hover_state(
trash_button, True, pinned=True
),
on_leave_notify_event=lambda *_: self._set_item_hover_state(
trash_button, False, pinned=True
),
)
trash_button.add_style_class("shown")
trash_button.is_trash = True
self.pinned_buttons["trash"] = trash_button
self.pinned_apps_container.add(trash_button)
self.pinned_items_pos.append(trash_button)
def _find_desktop_app(self, app_info, desktop_apps):
"""Find a desktop app from either a string ID or a data dictionary"""
if not app_info:
return None
# Extract normalized search terms
search_terms = []
if isinstance(app_info, dict):
search_terms = [
app_info.get("window_class"),
app_info.get("name"),
app_info.get("executable"),
]
else:
search_terms = [app_info]
search_terms = [s.lower() for s in search_terms if s and isinstance(s, str)]
if not search_terms:
return None
# Handle Reverse DNS or decorated names (e.g., org.gnome.Nautilus, float_kitty)
expanded_terms = list(search_terms)
for term in search_terms:
# Handle dots (Reverse DNS)
if "." in term:
parts = term.split(".")
if parts[-1] and parts[-1] not in expanded_terms:
expanded_terms.append(parts[-1])
# Handle underscores and hyphens (decorated names)
for sep in ["_", "-"]:
if sep in term:
for part in term.split(sep):
if len(part) > 2 and part not in expanded_terms:
expanded_terms.append(part)
# Priority 1: Exact matches for window_class
for app in desktop_apps:
app_class = getattr(app, "window_class", None)
if app_class and app_class.lower() in search_terms:
return app
# Priority 2: Exact matches for app name or display name
for app in desktop_apps:
app_names = [
getattr(app, "name", None),
getattr(app, "display_name", None),
]
app_names = [n.lower() for n in app_names if n]
if any(name in expanded_terms for name in app_names):
return app
# Priority 3: Exact matches for executable basename
for app in desktop_apps:
if app.executable:
exe_base = os.path.basename(app.executable).lower()
if exe_base in expanded_terms:
# If this is a common terminal or shell, only match if the name also matches
# This prevents Neovim (which uses 'kitty' as its executable wrapper) from overriding Kitty
common_wrappers = [
"kitty",
"bash",
"sh",
"zsh",
"python",
"python3",
]
if exe_base in common_wrappers:
app_names = [
getattr(app, "name", ""),
getattr(app, "display_name", ""),
]
if not any(exe_base in n.lower() for n in app_names if n):
continue
return app
# Priority 4: Fuzzy name matching (containment)
for app in desktop_apps:
app_terms = [
getattr(app, "name", ""),
getattr(app, "display_name", ""),
getattr(app, "window_class", ""),
]
app_terms = [t.lower() for t in app_terms if t]
for term in expanded_terms:
if len(term) < 3:
continue
if any(term in app_term for app_term in app_terms):
return app
return None
def show_menu(self, app_id: str, client=None, instance_address=None):
for item in self.menu.get_children():
self.menu.remove(item)
item.destroy()
if client or instance_address:
close_item = Gtk.MenuItem(label="Close")
if instance_address:
close_item.connect(
"activate", lambda *_: self._close_running_app(instance_address)
)
self.menu.add(close_item)
if app_id:
separator = Gtk.SeparatorMenuItem()
self.menu.add(separator)
if app_id:
is_pinned = self._is_app_pinned(app_id)
pin_item = Gtk.MenuItem(label="Unpin" if is_pinned else "Pin")
if is_pinned:
pin_item.connect("activate", lambda *_: self._unpin_app(app_id))
else:
pin_item.connect("activate", lambda *_: self._pin_app(app_id))
self.menu.add(pin_item)
self.menu.show_all()
def _close_running_app(self, instance_address):
try:
self._hyprland_connection.send_command(
f"dispatch closewindow address:{instance_address}"
)
except Exception as e:
logger.error(f"[AppBar] Error closing window: {e}")
def _handle_pinned_app_click(self, event, app_data):
app_identifier = self._get_app_identifier(app_data)
if event.button == 1: # Left click - launch app
self._launch_app(app_data)
elif event.button == 2: # Middle click - unpin app
self._unpin_app(app_identifier)
elif event.button == 3: # Right click - show context menu
self.show_menu(app_identifier)
self.menu.popup_at_pointer(event)
def _handle_trash_click(self, event):
"""Handle trash button click to open trash in file manager"""
if event.button == 1: # Left click
try:
trash_path = os.path.expanduser("~/.local/share/Trash/files")
file_managers = [
"nautilus",
"dolphin",
"thunar",
"nemo",
"caja",
"pcmanfm",
]
for fm in file_managers:
try:
result = subprocess.run(
["which", fm], capture_output=True, text=True
)
if result.returncode == 0:
subprocess.Popen([fm, trash_path])
return
except Exception:
continue
except Exception as e:
logger.error(f"[AppBar] Error opening trash: {e}")
def _set_item_hover_state(self, item, is_hovered, pinned=False):
if is_hovered:
self._parent.on_hover_enter()
items_list = self.pinned_items_pos if pinned else self.running_items_pos
try:
index = items_list.index(item)
style_class = "semi_hovered"
action = "add" if is_hovered else "remove"
if index > 0:
getattr(items_list[index - 1], f"{action}_style_class")(style_class)
if index < len(items_list) - 1:
getattr(items_list[index + 1], f"{action}_style_class")(style_class)
except (ValueError, IndexError):
pass
def _launch_app(self, app_info):
"""Launch an app using its desktop app instance or a data dictionary"""
try:
command_line = ""
if hasattr(app_info, "command_line"):
command_line = app_info.command_line
elif isinstance(app_info, dict):
command_line = app_info.get("command_line") or app_info.get(
"executable"
)
elif isinstance(app_info, str):
command_line = app_info
if not command_line:
# Fallback to desktop app search if it's just a string class
desktop_apps = get_desktop_applications(include_hidden=False)
app = self._find_desktop_app(app_info, desktop_apps)
if app:
command_line = app.command_line
if command_line:
cleaned_command = re.sub(r"%\w+", "", command_line).strip()
final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
subprocess.Popen(final_command, shell=True)
elif hasattr(app_info, "launch"):
app_info.launch()
else:
logger.error(
f"[AppBar] Could not determine launch command for: {app_info}"
)
except Exception as e:
logger.error(f"[AppBar] Failed to launch app: {e}")
def _get_app_identifier(self, app_data):
if isinstance(app_data, dict):
return app_data.get("name", "") or app_data.get("window_class", "")
return app_data
def _pin_app(self, app_class: str):
if self._is_app_pinned(app_class):
return False
try:
desktop_apps = get_desktop_applications(include_hidden=False)
app = self._find_desktop_app(app_class, desktop_apps)
app_data = {
"name": app.name if app else app_class,
"display_name": (app.display_name or app.name) if app else app_class,
"window_class": (getattr(app, "window_class", None) or app_class)
if app
else app_class,
"executable": app.executable if app else app_class,
"command_line": app.command_line if app else app_class,
}
self.pinned_apps.append(app_data)
except Exception:
self.pinned_apps.append(app_class)
write_json_file(self.pinned_apps, PINNED_APPS_FILE)
self._populate_pinned_apps()
return True
def _unpin_app(self, app_identifier: str):
apps_to_remove = []
for i, pinned_app in enumerate(self.pinned_apps):
if self._matches_app_identifier(pinned_app, app_identifier):
apps_to_remove.append(i)
for i in reversed(apps_to_remove):
self.pinned_apps.pop(i)
if apps_to_remove:
write_json_file(self.pinned_apps, PINNED_APPS_FILE)
self._populate_pinned_apps()
return True
return False
def _matches_app_identifier(self, pinned_app, app_identifier):
if not app_identifier:
return False
if isinstance(pinned_app, dict):
window_class = pinned_app.get("window_class") or ""
name = pinned_app.get("name") or ""
return (
window_class.lower() == app_identifier.lower()
or name.lower() == app_identifier.lower()
)
return (
isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower()
)
def get_clients(self):
try:
clients_data = self._hyprland_connection.send_command("j/clients").reply
if not clients_data:
return []
return json.loads(clients_data.decode("utf-8"))
except Exception as e:
logger.error(f"[AppBar] Error getting clients: {e}")
return []
def get_focused_window(self):
try:
active_data = self._hyprland_connection.send_command("j/activewindow").reply
if not active_data:
return None
return json.loads(active_data.decode("utf-8"))
except Exception as e:
logger.error(f"[AppBar] Error getting focused window: {e}")
return None
def update_dock_apps(self):
try:
clients = self.get_clients()
focused_window = self.get_focused_window()
focused_address = (
focused_window.get("address", "") if focused_window else ""
)
current_instance_ids = set()
for client in clients:
if client.get("hidden", False) or not self._should_show_app_instance(
client
):
continue
instance_address = client.get("address", "")
app_class = client.get("class", "") or client.get("title", "")
if not instance_address or not app_class:
continue
current_instance_ids.add(instance_address)
if instance_address not in self.client_buttons:
self.create_instance_button(instance_address, client, app_class)
else:
self.update_instance_button(instance_address, client, app_class)
button = self.client_buttons[instance_address]
if instance_address == focused_address:
button.add_style_class("activated")
else:
button.remove_style_class("activated")
self._update_pinned_apps_state(clients)
self._update_separator_visibility()
self._cleanup_removed_instances(current_instance_ids)
except Exception as e:
logger.error(f"[AppBar] Error in update_dock_apps: {e}")
def _update_pinned_apps_state(self, clients):
running_app_classes = {
client.get("class", "").lower() or client.get("title", "").lower()
for client in clients
if not client.get("hidden", False)
and (client.get("class") or client.get("title"))
and self._should_show_app_instance(client)
}
for app_identifier, button in self.pinned_buttons.items():
# Skip trash button as it's not a regular app
if app_identifier == "trash" or hasattr(button, "is_trash"):
continue
if app_identifier.lower() in running_app_classes:
button.add_style_class("instance")
else:
button.remove_style_class("instance")
def _cleanup_removed_instances(self, current_instance_ids):
buttons_to_remove = [
instance_id
for instance_id in self.client_buttons.keys()
if instance_id not in current_instance_ids
]
# Clean up removed and orphaned buttons
for instance_id in buttons_to_remove + [
k
for k, v in self.client_buttons.items()
if not hasattr(v, "instance_address") or not v.get_parent()
]:
if instance_id in self.client_buttons:
button = self.client_buttons.pop(instance_id)
try:
if button in self.running_items_pos:
self.running_items_pos.remove(button)
button.remove_style_class("shown")
button.remove_style_class("activated")
if button.get_parent():
button.get_parent().remove(button)
button.destroy()
except Exception as e:
logger.warning(f"[AppBar] Error during cleanup: {e}")
def create_instance_button(self, instance_address, client, app_class):
try:
client_image = Image(name="dock_item_icon")
try:
desktop_apps = get_desktop_applications(include_hidden=False)
app = self._find_desktop_app(app_class, desktop_apps)
pixbuf = self._get_app_icon(app_class, app)
client_image.set_from_pixbuf(pixbuf)
except Exception as e:
logger.warning(f"[AppBar] Could not load icon for {app_class}: {e}")
workspace_id = self._get_workspace_id(client)
workspace_label = None
if workspace_id is not None:
workspace_label = Label(
label=str(workspace_id),
name="workspace-indicator",
h_align="end",
v_align="end",
)
image_overlay = Overlay(name="dock-image-overlay", child=client_image)
if workspace_label:
image_overlay.add_overlay(workspace_label)
indicator = Box(name="dock_item_indicator", h_align="center")
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[image_overlay, indicator],
)
tooltip_text = client.get("title", app_class)
if tooltip_text != app_class:
tooltip_text = f"{app_class}: {tooltip_text}"
client_button = Button(
name="dock_item",
child=main_container,
tooltip_text=tooltip_text,
on_button_press_event=lambda widget, event: self.handle_instance_click(
widget, event
),
on_enter_notify_event=lambda *_: self._set_item_hover_state(
client_button, True, pinned=False
),
on_leave_notify_event=lambda *_: self._set_item_hover_state(
client_button, False, pinned=False
),
)
client_button.instance_address = instance_address
client_button.client_data = client
client_button.app_class = app_class
client_button.workspace_label = workspace_label
client_button.add_style_class("shown")
self.client_buttons[instance_address] = client_button
self.running_apps_container.add(client_button)
self.running_items_pos.append(client_button)
except Exception as e:
logger.error(
f"[AppBar] Error creating instance button for {app_class}: {e}"
)
def _get_workspace_id(self, client):
workspace_data = client.get("workspace", {})
if isinstance(workspace_data, dict):
return workspace_data.get("id")
elif isinstance(workspace_data, (int, str)):
return workspace_data
return None
def _should_show_app_instance(self, client):
if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS:
return True
workspace_id = self._get_workspace_id(client)
return (
False if workspace_id is None else not is_special_workspace_id(workspace_id)
)
def update_instance_button(self, instance_address, client, app_class):
if instance_address not in self.client_buttons:
return
button = self.client_buttons[instance_address]
button.client_data = client
button.app_class = app_class
tooltip_text = client.get("title", app_class)
if tooltip_text != app_class:
tooltip_text = f"{app_class}: {tooltip_text}"
button.set_tooltip_text(tooltip_text)
workspace_id = self._get_workspace_id(client)
existing_label = getattr(button, "workspace_label", None)
container = button.get_child()
if not hasattr(container, "get_children"):
return
children = container.get_children()
if not children or not isinstance(children[0], Overlay):
return
image_overlay = children[0]
# Remove existing workspace label
if existing_label and existing_label.get_parent():
image_overlay.remove_overlay(existing_label)
# Add new workspace label if needed
if workspace_id is not None:
new_label = Label(
label=str(workspace_id),
name="workspace-indicator",
h_align="end",
v_align="end",
)
image_overlay.add_overlay(new_label)
button.workspace_label = new_label
else:
button.workspace_label = None
def handle_instance_click(self, button_widget, event):
instance_address = getattr(button_widget, "instance_address", None)
app_class = getattr(button_widget, "app_class", None)
if event.button == 1: # Left click - focus window
if instance_address:
try:
self._hyprland_connection.send_command(
f"dispatch focuswindow address:{instance_address}"
)
except Exception as e:
logger.error(f"[AppBar] Error focusing window: {e}")
elif event.button == 2: # Middle click - pin/unpin app
if app_class and not self._is_app_pinned(app_class):
self._pin_app(app_class)
elif event.button == 3: # Right click - context menu
if app_class:
self.show_menu(app_class, instance_address=instance_address)
self.menu.popup_at_pointer(event)
def _is_app_pinned(self, app_class: str) -> bool:
return any(
self._matches_app_identifier(pinned_app, app_class)
for pinned_app in self.pinned_apps
)
def _update_separator_visibility(self):
has_pinned_apps = len(self.pinned_items_pos) > 0
has_running_apps = len(self.running_items_pos) > 0
if has_pinned_apps and has_running_apps:
self.separator.remove_style_class("hidden")
else:
self.separator.add_style_class("hidden")
class Dock(Window):
def __init__(self):
dock_config = self._get_dock_config()
if not data.DOCK_ENABLED:
super().__init__(layer="top", title="dock", anchor=dock_config["anchor"])
self.children = Box() # Empty dock if disabled
return
super().__init__(layer="top", anchor=dock_config["anchor"])
self.app_bar = AppBar(self)
self.revealer = Revealer(
child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"),
transition_duration=200,
transition_type=dock_config["transition"],
)
self.children = EventBox(
events=["enter-notify", "leave-notify"],
child=Box(style="min-height: 1px", children=self.revealer),
on_enter_notify_event=lambda *_: self.on_hover_enter(),
on_leave_notify_event=lambda *_: self.on_hover_leave(),
)
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
self.dock_height = 100
self.is_hovered = False
self.hide_ticket = 0
# Only setup occlusion monitoring if auto-hide is enabled
if data.DOCK_AUTO_HIDE:
self.setup_occlusion_monitoring()
def on_hover_enter(self):
self.is_hovered = True
self.hide_ticket = random.getrandbits(32)
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
def on_hover_leave(self):
self.is_hovered = False
self.hide_ticket = random.getrandbits(32)
def delayed_hide(ticket):
if ticket == self.hide_ticket and not self.is_hovered:
# Only hide if we aren't occluded (or if occlusion logic says so)
# For now, just trigger the basic hide if auto-hide is on
if data.DOCK_AUTO_HIDE:
# Check if actually occluded before hiding if DOCK_ALWAYS_OCCLUDED is false
occlusion_pos = self._get_dock_config()["occlusion"]
is_occluded = data.DOCK_ALWAYS_OCCLUDED or check_occlusion(
(occlusion_pos, self.dock_height)
)
if is_occluded:
self.revealer.set_reveal_child(False)
self.app_bar.remove_style_class("shown")
return False
GLib.timeout_add(500, delayed_hide, self.hide_ticket)
def _get_dock_config(self):
config_map = {
"Left": {
"anchor": "left center",
"transition": "slide-right",
"occlusion": "left",
},
"Right": {
"anchor": "right center",
"transition": "slide-left",
"occlusion": "right",
},
"Bottom": {
"anchor": "bottom center",
"transition": "slide-up",
"occlusion": "bottom",
},
}
return config_map.get(data.DOCK_POSITION, config_map["Bottom"])
def setup_occlusion_monitoring(self):
def check_dock_occlusion():
try:
if data.DOCK_ALWAYS_OCCLUDED:
is_occluded = True
else:
occlusion_pos = self._get_dock_config()["occlusion"]
is_occluded = check_occlusion((occlusion_pos, self.dock_height))
if (
is_occluded
and not self.is_hovered
and self.revealer.get_reveal_child()
):
self.revealer.set_reveal_child(False)
self.app_bar.remove_style_class("shown")
elif not is_occluded and not self.revealer.get_reveal_child():
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
elif is_occluded and self.is_hovered:
if not self.revealer.get_reveal_child():
self.revealer.set_reveal_child(True)
self.app_bar.add_style_class("shown")
except Exception as e:
logger.error(f"[Dock] Occlusion check error: {e}")
return True
GLib.timeout_add(300, check_dock_occlusion)