feat(modules): improve dock auto-hide logic and refactor pinned app handling

New Features
- Introduce a random ticket mechanism to prevent rapid hide/show cycles.
- Add `_get_dock_config` helper to centralize position‑specific configuration.
- Refactor `_create_pinned_button` and `_populate_pinned_apps` for clarity and reuse.

Enhancements- Replace manual child removal with `clear_children` utility.
- Extract `_get_app_icon` to simplify icon resolution.
- Streamline imports and use explicit imports.

Bug Fixes
- Resolve hover race condition using ticket comparison.
- Ensure occlusion checks respect `DOCK_ALWAYS_OCCLUDED` correctly.
This commit is contained in:
sankalp committed 2026-04-09 22:41:12 +05:45
1 parent 70a0c2c9e0
commit b6ecc72af8
1 file changed
+213 -267
+213 -267
View File
@@ -1,5 +1,6 @@
import json
import os
import random
import re
import subprocess
@@ -14,7 +15,12 @@ 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
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
@@ -76,8 +82,7 @@ class AppBar(Box):
GLib.idle_add(self.update_dock_apps)
def _populate_pinned_apps(self):
for child in self.pinned_apps_container.get_children():
self.pinned_apps_container.remove(child)
clear_children(self.pinned_apps_container)
self.pinned_buttons = {}
self.pinned_items_pos = []
@@ -94,36 +99,24 @@ class AppBar(Box):
self._create_trash_button()
def _create_pinned_button(self, app_data, desktop_apps):
if isinstance(app_data, dict):
app_identifier = app_data.get("name", "") or app_data.get(
"window_class", ""
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
)
display_name = app_data.get("display_name", app_identifier)
app = self._find_desktop_app_from_data(app_data, desktop_apps)
)
if app:
icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
else:
icon_name = app_data.get("window_class", "") or app_data.get("name", "")
icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
icon_name, data.DOCK_ICON_SIZE
)
else:
app_identifier = app_data
app = self._find_desktop_app_by_id(app_data, desktop_apps)
if not app:
return
display_name = app.display_name or app.name
icon_pixbuf = app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
pinned_image = Image(name="dock_item_icon")
pinned_image.set_from_pixbuf(icon_pixbuf)
icon_pixbuf = self._get_app_icon(app_data, app)
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[pinned_image],
children=[Image(name="dock_item_icon", pixbuf=icon_pixbuf)],
)
pinned_button = Button(
@@ -133,11 +126,11 @@ class AppBar(Box):
on_button_press_event=lambda _, event: self._handle_pinned_app_click(
event, app_data
),
on_enter_notify_event=lambda *_: self._handle_item_hovered(
pinned_button, True
on_enter_notify_event=lambda *_: self._set_item_hover_state(
pinned_button, True, pinned=True
),
on_leave_notify_event=lambda *_: self._handle_item_unhovered(
pinned_button, True
on_leave_notify_event=lambda *_: self._set_item_hover_state(
pinned_button, False, pinned=True
),
)
@@ -147,20 +140,30 @@ class AppBar(Box):
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"""
# Get trash icon
trash_icon_pixbuf = self.icon_resolver.get_icon_pixbuf(
"user-trash", data.DOCK_ICON_SIZE
)
trash_image = Image(name="dock_item_icon")
trash_image.set_from_pixbuf(trash_icon_pixbuf)
main_container = Box(
name="dock_item_main_container",
orientation="v",
children=[trash_image],
children=[Image(name="dock_item_icon", pixbuf=trash_icon_pixbuf)],
)
trash_button = Button(
@@ -168,11 +171,11 @@ class AppBar(Box):
child=main_container,
tooltip_text="Trash",
on_button_press_event=lambda _, event: self._handle_trash_click(event),
on_enter_notify_event=lambda *_: self._handle_item_hovered(
trash_button, True
on_enter_notify_event=lambda *_: self._set_item_hover_state(
trash_button, True, pinned=True
),
on_leave_notify_event=lambda *_: self._handle_item_unhovered(
trash_button, True
on_leave_notify_event=lambda *_: self._set_item_hover_state(
trash_button, False, pinned=True
),
)
@@ -183,52 +186,45 @@ class AppBar(Box):
self.pinned_apps_container.add(trash_button)
self.pinned_items_pos.append(trash_button)
def _find_desktop_app_from_data(self, app_data: dict, desktop_apps):
for app in desktop_apps:
if (
(
app_data.get("name")
and app.name
and app.name.lower() == app_data["name"].lower()
)
or (
app_data.get("window_class")
and hasattr(app, "window_class")
and app.window_class
and app.window_class.lower() == app_data["window_class"].lower()
)
or (
app_data.get("executable")
and app.executable
and (
app.executable.lower() == app_data["executable"].lower()
or os.path.basename(app.executable).lower()
== os.path.basename(app_data["executable"]).lower()
)
)
):
return app
return None
def _find_desktop_app(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("name"),
app_info.get("window_class"),
app_info.get("executable"),
]
else:
search_terms = [app_info]
search_terms = [s.lower() for s in search_terms if s and isinstance(s, str)]
def _find_desktop_app_by_id(self, app_id: str, desktop_apps):
for app in desktop_apps:
if (
(app.name and app.name.lower() == app_id.lower())
or (app.display_name and app.display_name.lower() == app_id.lower())
or (
hasattr(app, "window_class")
and app.window_class
and app.window_class.lower() == app_id.lower()
)
or (
app.executable
and (
app.executable.lower() == app_id.lower()
or os.path.basename(app.executable).lower() == app_id.lower()
)
)
app_terms = [
app.name,
getattr(app, "display_name", None),
getattr(app, "window_class", None),
app.executable,
]
app_terms = [a.lower() for a in app_terms if a and isinstance(a, str)]
# Check for exact matches first
if any(term in app_terms for term in search_terms):
return app
# Then check for basename matches on executable
if any(
os.path.basename(app.executable).lower() == term
for term in search_terms
if app.executable
):
return app
return None
def show_menu(self, app_id: str, client=None, instance_address=None):
@@ -270,13 +266,12 @@ class AppBar(Box):
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_data(app_data)
self._launch_app(app_data)
elif event.button == 2: # Middle click - unpin app
app_identifier = self._get_app_identifier(app_data)
self._unpin_app(app_identifier)
elif event.button == 3: # Right click - show context menu
app_identifier = self._get_app_identifier(app_data)
self.show_menu(app_identifier)
self.menu.popup_at_pointer(event)
@@ -307,123 +302,78 @@ class AppBar(Box):
except Exception as e:
logger.error(f"[AppBar] Error opening trash: {e}")
def _handle_item_hovered(self, item, pinned=False):
if pinned:
try:
index = self.pinned_items_pos.index(item)
if index > 0:
self.pinned_items_pos[index - 1].add_style_class("semi_hovered")
if index < len(self.pinned_items_pos) - 1:
self.pinned_items_pos[index + 1].add_style_class("semi_hovered")
except ValueError:
pass
else:
try:
index = self.running_items_pos.index(item)
if index > 0:
self.running_items_pos[index - 1].add_style_class("semi_hovered")
if index < len(self.running_items_pos) - 1:
self.running_items_pos[index + 1].add_style_class("semi_hovered")
except ValueError:
pass
def _set_item_hover_state(self, item, is_hovered, pinned=False):
if is_hovered:
self._parent.on_hover_enter()
def _handle_item_unhovered(self, item, pinned=False):
if pinned:
try:
index = self.pinned_items_pos.index(item)
if index > 0:
self.pinned_items_pos[index - 1].remove_style_class("semi_hovered")
if index < len(self.pinned_items_pos) - 1:
self.pinned_items_pos[index + 1].remove_style_class("semi_hovered")
except ValueError:
pass
else:
try:
index = self.running_items_pos.index(item)
if index > 0:
self.running_items_pos[index - 1].remove_style_class("semi_hovered")
if index < len(self.running_items_pos) - 1:
self.running_items_pos[index + 1].remove_style_class("semi_hovered")
except ValueError:
pass
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 _launch_app_data(self, app_data):
try:
desktop_apps = get_desktop_applications(include_hidden=False)
if isinstance(app_data, dict):
app = self._find_desktop_app_from_data(app_data, desktop_apps)
if app:
self._launch_app(app)
else:
self._launch_app_from_data(app_data)
else:
app = self._find_desktop_app_by_id(app_data, desktop_apps)
if app:
self._launch_app(app)
except Exception as e:
logger.error(f"[AppBar] Failed to launch app: {e}")
def _launch_app(self, app):
try:
cleaned_command = re.sub(r"%\w+", "", app.command_line).strip()
final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
subprocess.Popen(final_command, shell=True)
except Exception:
try:
app.launch()
except Exception as fallback_error:
logger.error(f"[AppBar] Failed to launch app: {fallback_error}")
def _launch_app_from_data(self, app_data):
try:
command_line = app_data.get("command_line", "")
if command_line:
cleaned_command = re.sub(r"%\w+", "", command_line).strip()
final_command = f"hyprctl dispatch exec 'uwsm app -- {cleaned_command}'"
subprocess.Popen(final_command, shell=True)
elif app_data.get("executable"):
final_command = (
f"hyprctl dispatch exec 'uwsm app -- {app_data['executable']}'"
)
subprocess.Popen(final_command, shell=True)
else:
logger.error(
f"[AppBar] No command or executable found for app: {app_data}"
)
except Exception as e:
logger.error(f"[AppBar] Failed to launch app from data: {e}")
def _pin_app(self, app_class: str):
if self._is_app_pinned(app_class):
return False
try:
desktop_apps = get_desktop_applications(include_hidden=False)
app = self._find_desktop_app_by_id(app_class, desktop_apps)
if app:
app_data = {
"name": app.name,
"display_name": app.display_name or app.name,
"window_class": getattr(app, "window_class", None) or app_class,
"executable": app.executable,
"command_line": app.command_line,
}
else:
app_data = {
"name": app_class,
"display_name": app_class,
"window_class": app_class,
"executable": app_class,
"command_line": app_class,
}
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)
@@ -576,15 +526,8 @@ class AppBar(Box):
try:
desktop_apps = get_desktop_applications(include_hidden=False)
desktop_app = self._find_desktop_app_by_id(app_class, desktop_apps)
if desktop_app:
pixbuf = desktop_app.get_icon_pixbuf(data.DOCK_ICON_SIZE)
else:
pixbuf = self.icon_resolver.get_icon_pixbuf(
app_class, data.DOCK_ICON_SIZE
)
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}")
@@ -621,11 +564,11 @@ class AppBar(Box):
on_button_press_event=lambda widget, event: self.handle_instance_click(
widget, event
),
on_enter_notify_event=lambda *_: self._handle_item_hovered(
client_button, False
on_enter_notify_event=lambda *_: self._set_item_hover_state(
client_button, True, pinned=False
),
on_leave_notify_event=lambda *_: self._handle_item_unhovered(
client_button, False
on_leave_notify_event=lambda *_: self._set_item_hover_state(
client_button, False, pinned=False
),
)
@@ -652,18 +595,14 @@ class AppBar(Box):
return workspace_data
return None
def _is_special_workspace_id(self, ws_id):
return is_special_workspace_id(ws_id)
def _should_show_app_instance(self, client):
if not data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS:
return True
workspace_id = self._get_workspace_id(client)
if workspace_id is None:
return True
return not self._is_special_workspace_id(workspace_id)
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:
@@ -682,27 +621,30 @@ class AppBar(Box):
existing_label = getattr(button, "workspace_label", None)
container = button.get_child()
if hasattr(container, "get_children"):
children = container.get_children()
if children:
image_overlay = children[0]
if isinstance(image_overlay, Overlay):
# Remove existing workspace label
if existing_label and existing_label.get_parent():
image_overlay.remove_overlay(existing_label)
if not hasattr(container, "get_children"):
return
# 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
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)
@@ -743,23 +685,19 @@ class AppBar(Box):
class Dock(Window):
def __init__(self):
dock_config = self._get_dock_config()
if not data.DOCK_ENABLED:
anchor = self._get_anchor_from_position()
super().__init__(layer="top", title="dock", anchor=anchor)
super().__init__(layer="top", title="dock", anchor=dock_config["anchor"])
self.children = Box() # Empty dock if disabled
return
anchor = self._get_anchor_from_position()
super().__init__(layer="top", anchor=anchor)
super().__init__(layer="top", anchor=dock_config["anchor"])
self.app_bar = AppBar(self)
transition_type = self._get_transition_type()
self.revealer = Revealer(
child=Box(children=[self.app_bar], style="padding: 20px 50px 5px 50px;"),
transition_duration=200,
transition_type=transition_type,
transition_type=dock_config["transition"],
)
self.children = EventBox(
@@ -774,7 +712,7 @@ class Dock(Window):
self.dock_height = 100
self.is_hovered = False
self.hide_timeout_id = None
self.hide_ticket = 0
# Only setup occlusion monitoring if auto-hide is enabled
if data.DOCK_AUTO_HIDE:
@@ -782,42 +720,50 @@ class Dock(Window):
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.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
# Add small delay before potential hiding to prevent rapid show/hide cycles
if self.hide_timeout_id:
GLib.source_remove(self.hide_timeout_id)
self.hide_timeout_id = GLib.timeout_add(100, lambda: None)
self.hide_ticket = random.getrandbits(32)
def _get_anchor_from_position(self):
if data.DOCK_POSITION == "Left":
return "left center"
elif data.DOCK_POSITION == "Right":
return "right center"
else: # Bottom (default)
return "bottom center"
def 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
def _get_transition_type(self):
if data.DOCK_POSITION == "Left":
return "slide-right"
elif data.DOCK_POSITION == "Right":
return "slide-left"
else: # Bottom (default)
return "slide-up"
GLib.timeout_add(500, delayed_hide, self.hide_ticket)
def _get_occlusion_position(self):
if data.DOCK_POSITION == "Left":
return ("left", self.dock_height)
elif data.DOCK_POSITION == "Right":
return ("right", self.dock_height)
else: # Bottom (default)
return ("bottom", self.dock_height)
def _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():
@@ -825,8 +771,8 @@ class Dock(Window):
if data.DOCK_ALWAYS_OCCLUDED:
is_occluded = True
else:
occlusion_position = self._get_occlusion_position()
is_occluded = check_occlusion(occlusion_position)
occlusion_pos = self._get_dock_config()["occlusion"]
is_occluded = check_occlusion((occlusion_pos, self.dock_height))
if (
is_occluded