858 lines
37 KiB
Python
858 lines
37 KiB
Python
import json
|
|
import logging
|
|
|
|
import cairo
|
|
from fabric.hyprland.widgets import get_hyprland_connection
|
|
from fabric.utils import (exec_shell_command, exec_shell_command_async,
|
|
get_relative_path, idle_add, remove_handler)
|
|
from fabric.utils.helpers import get_desktop_applications
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.button import Button
|
|
from fabric.widgets.eventbox import EventBox
|
|
from fabric.widgets.image import Image
|
|
from fabric.widgets.revealer import Revealer
|
|
from gi.repository import Gdk, GLib, Gtk
|
|
|
|
import config.data as data
|
|
from modules.corners import MyCorner
|
|
from utils.icon_resolver import IconResolver
|
|
from widgets.wayland import WaylandWindow as Window
|
|
|
|
|
|
def read_config():
|
|
"""Read and return the full configuration from the JSON file, handling missing file."""
|
|
config_path = get_relative_path("../config/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, monitor_id: int = 0, integrated_mode: bool = False, **kwargs):
|
|
self.monitor_id = monitor_id
|
|
|
|
self.integrated_mode = integrated_mode
|
|
self.icon_size = 20 if self.integrated_mode else data.DOCK_ICON_SIZE
|
|
self.effective_occlusion_size = 36 + self.icon_size
|
|
self.always_show = data.DOCK_ALWAYS_SHOW if not self.integrated_mode else False
|
|
|
|
anchor_to_set: str
|
|
revealer_transition_type: str
|
|
|
|
self.actual_dock_is_horizontal: bool
|
|
main_box_orientation_val: Gtk.Orientation
|
|
main_box_h_align_val: str
|
|
dock_wrapper_orientation_val: Gtk.Orientation
|
|
|
|
if not self.integrated_mode:
|
|
|
|
self.actual_dock_is_horizontal = not data.VERTICAL
|
|
|
|
if self.actual_dock_is_horizontal:
|
|
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
|
|
else:
|
|
|
|
if data.BAR_POSITION == "Left":
|
|
anchor_to_set = "right"
|
|
revealer_transition_type = "slide-left"
|
|
elif data.BAR_POSITION == "Right":
|
|
anchor_to_set = "left"
|
|
revealer_transition_type = "slide-right"
|
|
else:
|
|
anchor_to_set = "right"
|
|
revealer_transition_type = "slide-left"
|
|
|
|
main_box_orientation_val = Gtk.Orientation.HORIZONTAL
|
|
main_box_h_align_val = "end" if anchor_to_set == "right" else "start"
|
|
dock_wrapper_orientation_val = Gtk.Orientation.VERTICAL
|
|
|
|
super().__init__(
|
|
name="dock-window",
|
|
layer="top",
|
|
anchor=anchor_to_set,
|
|
margin="0px 0px 0px 0px",
|
|
exclusivity="auto" if self.always_show else "none",
|
|
monitor=monitor_id,
|
|
**kwargs,
|
|
)
|
|
Dock._instances.append(self)
|
|
else:
|
|
self.actual_dock_is_horizontal = True
|
|
dock_wrapper_orientation_val = Gtk.Orientation.HORIZONTAL
|
|
|
|
anchor_to_set = "bottom"
|
|
revealer_transition_type = "slide-up"
|
|
main_box_orientation_val = Gtk.Orientation.VERTICAL
|
|
main_box_h_align_val = "center"
|
|
|
|
if not self.integrated_mode:
|
|
match data.BAR_POSITION:
|
|
case "Top":
|
|
self.set_margin("-8px 0px 0px 0px")
|
|
case "Bottom":
|
|
self.set_margin("0px 0px 0px 0px")
|
|
case "Left":
|
|
self.set_margin("0px 0px 0px -8px")
|
|
case "Right":
|
|
self.set_margin("0px -8px 0px 0px")
|
|
case _:
|
|
self.set_margin("0px 0px 0px 0px")
|
|
|
|
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/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.is_mouse_over_dock_area = False
|
|
self._prevent_occlusion = False
|
|
self._forced_occlusion = False
|
|
|
|
self.view = Box(name="viewport", spacing=4)
|
|
self.wrapper = Box(name="dock", children=[self.view], style_classes=["left"] if data.BAR_POSITION == "Right" else [])
|
|
|
|
self.wrapper.set_orientation(dock_wrapper_orientation_val)
|
|
self.view.set_orientation(dock_wrapper_orientation_val)
|
|
|
|
if self.integrated_mode:
|
|
self.wrapper.add_style_class("integrated")
|
|
else:
|
|
|
|
if dock_wrapper_orientation_val == Gtk.Orientation.VERTICAL:
|
|
self.wrapper.add_style_class("vertical")
|
|
else:
|
|
self.wrapper.remove_style_class("vertical")
|
|
|
|
match data.DOCK_THEME:
|
|
case "Pills":
|
|
self.wrapper.add_style_class("pills")
|
|
case "Dense":
|
|
self.wrapper.add_style_class("dense")
|
|
case "Edge":
|
|
self.wrapper.add_style_class("edge")
|
|
case _:
|
|
self.wrapper.add_style_class("pills")
|
|
|
|
if not self.integrated_mode:
|
|
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)
|
|
|
|
self.corner_left = Box()
|
|
self.corner_right = Box()
|
|
self.corner_top = Box()
|
|
self.corner_bottom = Box()
|
|
|
|
if self.actual_dock_is_horizontal:
|
|
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.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]
|
|
)
|
|
else:
|
|
|
|
|
|
|
|
|
|
if anchor_to_set == "right":
|
|
self.corner_top = Box(
|
|
name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start",
|
|
children=[Box(h_expand=True, h_align="fill"), MyCorner("bottom-right")]
|
|
)
|
|
self.corner_bottom = Box(
|
|
name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end",
|
|
children=[Box(h_expand=True, h_align="fill"), MyCorner("top-right")]
|
|
)
|
|
else:
|
|
self.corner_top = Box(
|
|
name="dock-corner-top", orientation=Gtk.Orientation.HORIZONTAL, v_align="start",
|
|
children=[MyCorner("bottom-left"), Box(h_expand=True, h_align="fill")]
|
|
)
|
|
self.corner_bottom = Box(
|
|
name="dock-corner-bottom", orientation=Gtk.Orientation.HORIZONTAL, v_align="end",
|
|
children=[MyCorner("top-left"), Box(h_expand=True, h_align="fill")]
|
|
)
|
|
|
|
self.dock_full = Box(
|
|
name="dock-full", orientation=Gtk.Orientation.VERTICAL, v_expand=True, v_align="fill",
|
|
children=[self.corner_top, self.dock_eventbox, self.corner_bottom]
|
|
)
|
|
|
|
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 if self.actual_dock_is_horizontal else 1, 1 if self.actual_dock_is_horizontal else -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] if data.BAR_POSITION != "Right" else [self.dock_revealer, self.hover_activator],
|
|
h_align=main_box_h_align_val,
|
|
)
|
|
self.add(self.main_box)
|
|
|
|
if data.DOCK_THEME in ["Edge", "Dense"]:
|
|
for corner in [self.corner_left, self.corner_right, self.corner_top, self.corner_bottom]:
|
|
corner.set_visible(False)
|
|
|
|
|
|
# Hide normal dock when it should be embedded in the bar OR when dock is disabled
|
|
should_be_embedded = (data.BAR_POSITION == "Bottom") or (data.PANEL_THEME == "Panel" and data.BAR_POSITION in ["Top", "Bottom"])
|
|
if should_be_embedded or not data.DOCK_ENABLED:
|
|
self.set_visible(False)
|
|
|
|
if self.always_show:
|
|
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()
|
|
if not self.integrated_mode: GLib.timeout_add(500, self.check_occlusion_state)
|
|
else:
|
|
self.conn.connect("event::ready", self.update_dock)
|
|
if not self.integrated_mode: self.conn.connect("event::ready", lambda *args: GLib.timeout_add(250, self.check_occlusion_state))
|
|
|
|
# Listen to window events to update dock when apps open/close
|
|
self.conn.connect("event::openwindow", self.update_dock)
|
|
self.conn.connect("event::closewindow", self.update_dock)
|
|
|
|
if not self.integrated_mode:
|
|
self.conn.connect("event::workspace", self.check_hide)
|
|
|
|
GLib.timeout_add_seconds(2, 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):
|
|
if self.integrated_mode: return
|
|
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_show:
|
|
self.dock_full.remove_style_class("occluded")
|
|
|
|
def _on_hover_leave(self, *args):
|
|
if self.integrated_mode: return
|
|
self.is_mouse_over_dock_area = False
|
|
if self._forced_occlusion:
|
|
self.dock_revealer.set_reveal_child(False)
|
|
else:
|
|
self.delay_hide()
|
|
|
|
def _on_dock_enter(self, widget, event):
|
|
if self.integrated_mode: return True
|
|
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_show:
|
|
self.dock_full.remove_style_class("occluded")
|
|
return True
|
|
|
|
def _on_dock_leave(self, widget, event):
|
|
if self.integrated_mode: return True
|
|
if event.detail == Gdk.NotifyType.INFERIOR:
|
|
return False
|
|
|
|
self.is_mouse_over_dock_area = False
|
|
|
|
if self._forced_occlusion:
|
|
self.dock_revealer.set_reveal_child(False)
|
|
else:
|
|
self.delay_hide()
|
|
|
|
if not self.always_show:
|
|
self.dock_full.add_style_class("occluded")
|
|
return True
|
|
|
|
def find_app(self, app_identifier):
|
|
if not app_identifier: return None
|
|
if isinstance(app_identifier, dict):
|
|
for key in ["window_class", "executable", "command_line", "name", "display_name"]:
|
|
if key in app_identifier and app_identifier[key]:
|
|
app = self.find_app_by_key(app_identifier[key])
|
|
if app: return app
|
|
return None
|
|
return self.find_app_by_key(app_identifier)
|
|
|
|
def find_app_by_key(self, key_value):
|
|
if not key_value: return None
|
|
normalized_id = str(key_value).lower()
|
|
if normalized_id in self.app_identifiers:
|
|
return self.app_identifiers[normalized_id]
|
|
for app in self._all_apps:
|
|
if app.name and normalized_id in app.name.lower(): return app
|
|
if app.display_name and normalized_id in app.display_name.lower(): return app
|
|
if app.window_class and normalized_id in app.window_class.lower(): return app
|
|
if app.executable and normalized_id in app.executable.lower(): return app
|
|
if app.command_line and normalized_id in app.command_line.lower(): return app
|
|
return None
|
|
|
|
def update_app_map(self):
|
|
self._all_apps = get_desktop_applications()
|
|
self.app_map = {app.name: app for app in self._all_apps if app.name}
|
|
self.app_identifiers = self._build_app_identifiers_map()
|
|
|
|
def create_button(self, app_identifier, instances):
|
|
desktop_app = self.find_app(app_identifier)
|
|
icon_img = None
|
|
display_name = None
|
|
|
|
if desktop_app:
|
|
icon_img = desktop_app.get_icon_pixbuf(size=self.icon_size)
|
|
display_name = desktop_app.display_name or desktop_app.name
|
|
|
|
id_value = app_identifier["name"] if isinstance(app_identifier, dict) else app_identifier
|
|
|
|
if not icon_img:
|
|
icon_img = self.icon_resolver.get_icon_pixbuf(id_value, self.icon_size)
|
|
|
|
if not icon_img:
|
|
icon_img = self.icon_resolver.get_icon_pixbuf("application-x-executable-symbolic", self.icon_size)
|
|
if not icon_img:
|
|
icon_img = self.icon_resolver.get_icon_pixbuf("image-missing", self.icon_size)
|
|
|
|
items = [Image(pixbuf=icon_img)]
|
|
tooltip = display_name or (id_value if isinstance(id_value, str) else "Unknown")
|
|
if not display_name and instances and instances[0].get("title"):
|
|
tooltip = instances[0]["title"]
|
|
|
|
button = Button(
|
|
child= Box(name="dock-icon", orientation="v", h_align="center", children=items),
|
|
on_clicked=lambda *a: self.handle_app(app_identifier, instances, desktop_app),
|
|
tooltip_text=tooltip, name="dock-app-button",
|
|
)
|
|
button.app_identifier = app_identifier
|
|
button.desktop_app = desktop_app
|
|
button.instances = instances
|
|
if instances: button.add_style_class("instance")
|
|
|
|
button.drag_source_set(
|
|
Gdk.ModifierType.BUTTON1_MASK,
|
|
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
|
|
Gdk.DragAction.MOVE
|
|
)
|
|
button.connect("drag-begin", self.on_drag_begin)
|
|
button.connect("drag-end", self.on_drag_end)
|
|
button.drag_dest_set(
|
|
Gtk.DestDefaults.ALL,
|
|
[Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)],
|
|
Gdk.DragAction.MOVE
|
|
)
|
|
button.connect("drag-data-get", self.on_drag_data_get)
|
|
button.connect("drag-data-received", self.on_drag_data_received)
|
|
button.connect("enter-notify-event", self._on_child_enter)
|
|
return button
|
|
|
|
def handle_app(self, app_identifier, instances, desktop_app=None):
|
|
if not instances:
|
|
if not desktop_app: desktop_app = self.find_app(app_identifier)
|
|
if desktop_app:
|
|
launch_success = desktop_app.launch()
|
|
if not launch_success:
|
|
if desktop_app.command_line: exec_shell_command_async(f"nohup {desktop_app.command_line} &")
|
|
elif desktop_app.executable: exec_shell_command_async(f"nohup {desktop_app.executable} &")
|
|
else:
|
|
cmd_to_run = None
|
|
if isinstance(app_identifier, dict):
|
|
if "command_line" in app_identifier and app_identifier["command_line"]: cmd_to_run = app_identifier['command_line']
|
|
elif "executable" in app_identifier and app_identifier["executable"]: cmd_to_run = app_identifier['executable']
|
|
elif "name" in app_identifier and app_identifier["name"]: cmd_to_run = app_identifier['name']
|
|
elif isinstance(app_identifier, str): cmd_to_run = app_identifier
|
|
if cmd_to_run: exec_shell_command_async(f"nohup {cmd_to_run} &")
|
|
else:
|
|
focused = self.get_focused()
|
|
idx = next((i for i, inst in enumerate(instances) if inst["address"] == focused), -1)
|
|
next_inst = instances[(idx + 1) % len(instances)]
|
|
exec_shell_command(f"hyprctl dispatch focuswindow address:{next_inst['address']}")
|
|
|
|
def _on_child_enter(self, widget, event):
|
|
if self.integrated_mode: return False
|
|
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.integrated_mode: return
|
|
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):
|
|
if self.integrated_mode:
|
|
return False
|
|
self.hide_id = None
|
|
if not self.is_mouse_over_dock_area and not self._drag_in_progress and not self._prevent_occlusion:
|
|
if not self.always_show:
|
|
self.dock_revealer.set_reveal_child(False)
|
|
return False
|
|
|
|
def check_hide(self, *args):
|
|
if self.integrated_mode:
|
|
return
|
|
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 self.always_show:
|
|
if not self.dock_revealer.get_reveal_child():
|
|
self.dock_revealer.set_reveal_child(True)
|
|
self.dock_full.remove_style_class("occluded")
|
|
else:
|
|
if self.dock_revealer.get_reveal_child():
|
|
self.dock_revealer.set_reveal_child(False)
|
|
self.dock_full.add_style_class("occluded")
|
|
|
|
def update_dock(self, *args):
|
|
self.update_app_map()
|
|
arranger_handler = getattr(self, "_arranger_handler", None)
|
|
if arranger_handler: remove_handler(arranger_handler)
|
|
clients = self.get_clients()
|
|
|
|
running_windows = {}
|
|
for c in clients:
|
|
window_id = None
|
|
if class_name := c.get("initialClass", "").lower(): window_id = class_name
|
|
elif class_name := c.get("class", "").lower(): window_id = class_name
|
|
elif title := c.get("title", "").lower():
|
|
possible_name = title.split(" - ")[0].strip()
|
|
if possible_name and len(possible_name) > 1: window_id = possible_name
|
|
else: window_id = title
|
|
if not window_id: window_id = "unknown-app"
|
|
running_windows.setdefault(window_id, []).append(c)
|
|
normalized_id = self._normalize_window_class(window_id)
|
|
if normalized_id != window_id:
|
|
running_windows.setdefault(normalized_id, []).extend(running_windows[window_id])
|
|
|
|
pinned_buttons = []
|
|
used_window_classes = set()
|
|
|
|
for app_data_item in self.pinned:
|
|
app = self.find_app(app_data_item)
|
|
instances = []
|
|
matched_class = None
|
|
possible_identifiers = []
|
|
|
|
if isinstance(app_data_item, dict):
|
|
for key in ["window_class", "executable", "command_line", "name", "display_name"]:
|
|
if key in app_data_item and app_data_item[key]: possible_identifiers.append(app_data_item[key].lower())
|
|
elif isinstance(app_data_item, str): possible_identifiers.append(app_data_item.lower())
|
|
|
|
if app:
|
|
if app.window_class: possible_identifiers.append(app.window_class.lower())
|
|
if app.executable: possible_identifiers.append(app.executable.split('/')[-1].lower())
|
|
if app.command_line:
|
|
cmd_parts = app.command_line.split()
|
|
if cmd_parts: possible_identifiers.append(cmd_parts[0].split('/')[-1].lower())
|
|
if app.name: possible_identifiers.append(app.name.lower())
|
|
if app.display_name: possible_identifiers.append(app.display_name.lower())
|
|
|
|
possible_identifiers = list(set(possible_identifiers))
|
|
|
|
for identifier in possible_identifiers:
|
|
if identifier in running_windows:
|
|
instances = running_windows[identifier]; matched_class = identifier; break
|
|
normalized = self._normalize_window_class(identifier)
|
|
if normalized in running_windows:
|
|
instances = running_windows[normalized]; matched_class = normalized; break
|
|
for window_class_key in running_windows:
|
|
if len(identifier) >= 3 and identifier in window_class_key:
|
|
instances = running_windows[window_class_key]; matched_class = window_class_key
|
|
break
|
|
if matched_class: break
|
|
|
|
if matched_class:
|
|
used_window_classes.add(matched_class)
|
|
used_window_classes.add(self._normalize_window_class(matched_class))
|
|
|
|
pinned_buttons.append(self.create_button(app_data_item, instances))
|
|
|
|
open_buttons = []
|
|
for class_name, instances in running_windows.items():
|
|
if class_name not in used_window_classes:
|
|
app = None
|
|
app = self.app_identifiers.get(class_name)
|
|
if not app:
|
|
norm_class = self._normalize_window_class(class_name)
|
|
app = self.app_identifiers.get(norm_class)
|
|
if not app: app = self.find_app_by_key(class_name)
|
|
if not app and instances and instances[0].get("title"):
|
|
title = instances[0].get("title", "")
|
|
potential_name = title.split(" - ")[0].strip()
|
|
if len(potential_name) > 2: app = self.find_app_by_key(potential_name)
|
|
|
|
if app:
|
|
app_data_obj = {
|
|
"name": app.name, "display_name": app.display_name,
|
|
"window_class": app.window_class, "executable": app.executable,
|
|
"command_line": app.command_line
|
|
}
|
|
identifier = app_data_obj
|
|
else: identifier = class_name
|
|
open_buttons.append(self.create_button(identifier, instances))
|
|
|
|
children = pinned_buttons
|
|
separator_orientation = Gtk.Orientation.VERTICAL if self.view.get_orientation() == Gtk.Orientation.HORIZONTAL else Gtk.Orientation.HORIZONTAL
|
|
if pinned_buttons and open_buttons:
|
|
children += [Box(orientation=separator_orientation, v_expand=False, h_expand=False, h_align="center", v_align="center", name="dock-separator")]
|
|
children += open_buttons
|
|
|
|
self.view.children = children
|
|
if not self.integrated_mode:
|
|
idle_add(self._update_size)
|
|
self._drag_in_progress = False
|
|
if not self.integrated_mode:
|
|
self.check_occlusion_state()
|
|
|
|
def _update_size(self):
|
|
if self.integrated_mode: return False
|
|
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.integrated_mode:
|
|
return False
|
|
|
|
# When forced occlusion is active, only show on hover
|
|
if self._forced_occlusion:
|
|
if self.is_mouse_over_dock_area:
|
|
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")
|
|
return True
|
|
|
|
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_show:
|
|
self.dock_full.remove_style_class("occluded")
|
|
return True
|
|
|
|
if self.always_show:
|
|
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")
|
|
|
|
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
|
|
if not self.integrated_mode:
|
|
self.check_occlusion_state()
|
|
|
|
GLib.idle_add(process_drag_end)
|
|
def check_config_change(self):
|
|
new_config = read_config()
|
|
if not self.integrated_mode:
|
|
new_always_show = data.DOCK_ALWAYS_SHOW
|
|
if self.always_show != new_always_show:
|
|
self.always_show = new_always_show
|
|
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/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()
|
|
|
|
if not self.integrated_mode:
|
|
previous_always_show = self.always_show
|
|
self.always_show = data.DOCK_ALWAYS_SHOW
|
|
|
|
if previous_always_show != self.always_show:
|
|
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)
|
|
|
|
def force_occlusion(self):
|
|
"""Force dock to hide and act as if always_show is False."""
|
|
if self.integrated_mode:
|
|
return
|
|
# Save current always_show state
|
|
self._saved_always_show = self.always_show
|
|
# Set to False to enable hover behavior
|
|
self.always_show = False
|
|
self._forced_occlusion = True
|
|
if not self.is_mouse_over_dock_area:
|
|
self.dock_revealer.set_reveal_child(False)
|
|
|
|
def restore_from_occlusion(self):
|
|
"""Restore dock to its previous always_show state."""
|
|
if self.integrated_mode:
|
|
return
|
|
self._forced_occlusion = False
|
|
# Restore saved always_show state
|
|
if hasattr(self, '_saved_always_show'):
|
|
self.always_show = self._saved_always_show
|
|
delattr(self, '_saved_always_show')
|
|
self.check_occlusion_state()
|