From 5b8d6a62aa0c4ab4b87938f55eecb1fe8c925e7b Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Wed, 8 Jul 2026 12:25:02 +0545 Subject: [PATCH] refactor! : drop subprocess usage and centralize brightness service initialization --- src/window/controlcenter/bluetooth.py | 8 +- src/window/controlcenter/main.py | 93 +- src/window/controlcenter/nightlight.py | 87 +- src/window/controlcenter/per_app_volume.py | 15 +- src/window/controlcenter/wifi.py | 12 +- src/window/desktop/widget.py | 7 + src/window/dock/__init__.py | 3 + src/window/dock/animator.py | 72 ++ src/window/{dock.py => dock/canvas.py} | 842 ++++++------------ src/window/dock/constants.py | 40 + src/window/dock/layout.py | 94 ++ src/window/dock/main.py | 163 ++++ src/window/notification/notification.py | 90 +- .../notification/notification_center.py | 32 +- src/window/notification/unified_cache.py | 21 +- src/window/osd/components/audio.py | 11 +- src/window/panel/components/custom_mods.py | 4 +- src/window/panel/components/globalmenu.py | 3 - src/window/panel/main.py | 5 +- src/window/panel/notch/player.py | 3 +- src/window/switcher.py | 57 +- 21 files changed, 834 insertions(+), 828 deletions(-) create mode 100644 src/window/dock/__init__.py create mode 100644 src/window/dock/animator.py rename src/window/{dock.py => dock/canvas.py} (58%) create mode 100644 src/window/dock/constants.py create mode 100644 src/window/dock/layout.py create mode 100644 src/window/dock/main.py diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py index 5d00f3f..b022c09 100644 --- a/src/window/controlcenter/bluetooth.py +++ b/src/window/controlcenter/bluetooth.py @@ -1,7 +1,6 @@ -import subprocess from enum import Enum, auto -from fabric.utils import Gdk, GLib, exec_shell_command, logger +from fabric.utils import Gdk, GLib, Gtk, exec_shell_command, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox @@ -12,6 +11,7 @@ from fabric.widgets.separator import Separator from services.bluetooth import BluetoothClient, BluetoothDevice from shared.widgets.smooth_switch import SmoothSwitch from shared.window.animated_scrollwindow import AnimatedScrollable +from utils.functions import spawn_detached from utils.utils import svg_file @@ -290,8 +290,6 @@ class BluetoothDeviceSlot(Box): if event.button != 3: # only right-click return False - from gi.repository import Gtk - menu = Gtk.Menu() if not self.device.paired: @@ -567,7 +565,7 @@ class BluetoothConnections(Box): def open_bluetooth_settings(self, *_): """Open Blueman bluetooth manager""" try: - subprocess.Popen(["blueman-manager"], start_new_session=True) + spawn_detached(["blueman-manager"]) if self.parent and hasattr(self.parent, "hide_controlcenter"): self.parent.hide_controlcenter() except FileNotFoundError: diff --git a/src/window/controlcenter/main.py b/src/window/controlcenter/main.py index b8949ad..032664b 100644 --- a/src/window/controlcenter/main.py +++ b/src/window/controlcenter/main.py @@ -1,6 +1,4 @@ -import subprocess - -from fabric.utils import Gdk, GLib, get_relative_path, idle_add, logger +from fabric.utils import Gdk, GLib, idle_add, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox @@ -9,6 +7,7 @@ from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay from services.brightness import Brightness +from services.modus import is_caffeine_active, toggle_caffeine from services.network import NetworkClient from shared.widgets.flat_scale import FlatScale from shared.window.applet_window import AppletWindow @@ -24,7 +23,14 @@ from window.controlcenter.per_app_volume import PerAppVolumeControl from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager from window.controlcenter.wifi import WifiConnections -brightness_service = Brightness() +_brightness_instance: "Brightness | None" = None + + +def get_brightness_service() -> "Brightness": + global _brightness_instance + if _brightness_instance is None: + _brightness_instance = Brightness() + return _brightness_instance class ModusControlCenter(AppletWindow): @@ -48,8 +54,6 @@ class ModusControlCenter(AppletWindow): # Flight mode and caffeine states self.flight_mode = False self.caffeine_mode = False - self._caffeine_process = None - # Loading flags - music and expanded player are no longer lazy loaded self._per_app_volume_initialized = False self._signals_connected = False # Track if signals are connected @@ -111,10 +115,10 @@ class ModusControlCenter(AppletWindow): h_expand=True, ) - current_brightness = brightness_service.screen_brightness + current_brightness = get_brightness_service().screen_brightness brightness_percentage = ( - int((current_brightness / brightness_service.max_screen) * 100) - if brightness_service.max_screen > 0 + int((current_brightness / get_brightness_service().max_screen) * 100) + if get_brightness_service().max_screen > 0 else 50 ) @@ -129,7 +133,7 @@ class ModusControlCenter(AppletWindow): ) # Only connect brightness controls if brightness service is available - if brightness_service.max_screen > 0: + if get_brightness_service().max_screen > 0: pass else: # Disable brightness scale if no backlight device available @@ -577,13 +581,15 @@ class ModusControlCenter(AppletWindow): ) # Connect brightness controls if brightness service is available - if brightness_service.max_screen > 0: + if get_brightness_service().max_screen > 0: self.brightness_scale.connect("value-changed", self.set_brightness) self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) self._signal_connections.append( ( - brightness_service, - brightness_service.connect("screen", self.brightness_changed), + get_brightness_service(), + get_brightness_service().connect( + "screen", self.brightness_changed + ), ) ) @@ -715,23 +721,8 @@ class ModusControlCenter(AppletWindow): self.per_app_volume_center_box.set_size_request(300, -1) def _check_initial_states(self): - # Check if caffeine is already running - use faster method - try: - # Use faster check with timeout - result = subprocess.run( - ["pgrep", "-f", "modus-inhibit"], - capture_output=True, - text=True, - timeout=0.5, # Add timeout to prevent hanging - ) - self.caffeine_mode = bool(result.stdout.strip()) - except (subprocess.TimeoutExpired, Exception): - self.caffeine_mode = False - - # Flight mode starts as False (normal mode) + self.caffeine_mode = is_caffeine_active() self.flight_mode = False - - # Update initial labels (will be set after widgets are created) GLib.timeout_add(100, self._update_initial_labels) def _update_initial_labels(self): @@ -778,30 +769,13 @@ class ModusControlCenter(AppletWindow): def toggle_caffeine(self, *_): try: - if self.caffeine_mode: - inhibit_script = get_relative_path("../../utils/inhibit.py") - subprocess.run(["python3", inhibit_script, "off"], check=False) - self.caffeine_mode = False - if self._caffeine_process: - try: - self._caffeine_process.terminate() - except Exception as e: - logger.error(f"An error occurred: {e}") - self._caffeine_process = None - else: - inhibit_script = get_relative_path("../../utils/inhibit.py") - self._caffeine_process = subprocess.Popen( - ["python3", inhibit_script, "on"], start_new_session=True - ) - self.caffeine_mode = True - + self.caffeine_mode = toggle_caffeine() self.caffeine_icon.dynamic_file( "applets/caffeine-on.svg" if self.caffeine_mode else "applets/caffeine-off.svg" ) self.caffeine_status_label.set_label("On" if self.caffeine_mode else "Off") - except Exception as e: logger.warning(f"Failed to toggle caffeine: {e}") @@ -816,17 +790,17 @@ class ModusControlCenter(AppletWindow): if not self._signals_connected: return self._updating_brightness = True - brightness_value = int((brightness / 100) * brightness_service.max_screen) - brightness_service.screen_brightness = brightness_value + brightness_value = int((brightness / 100) * get_brightness_service().max_screen) + get_brightness_service().screen_brightness = brightness_value self._updating_brightness = False def brightness_changed(self, _, brightness_value): if not self._signals_connected or self._updating_brightness: return - if brightness_service.max_screen > 0: + if get_brightness_service().max_screen > 0: brightness_percentage = int( - (brightness_value / brightness_service.max_screen) * 100 + (brightness_value / get_brightness_service().max_screen) * 100 ) GLib.idle_add( @@ -1214,22 +1188,7 @@ class ModusControlCenter(AppletWindow): logger.warning(f"Widget cleanup failed: {e}") def _cleanup_processes(self): - """Clean up any running processes""" - try: - # Clean up caffeine process - if self._caffeine_process: - try: - self._caffeine_process.terminate() - self._caffeine_process.wait(timeout=1) - except Exception as e: - logger.warning(f"Failed to terminate caffeine process: {e}") - finally: - self._caffeine_process = None - - logger.debug("All processes cleaned up successfully") - - except Exception as e: - logger.warning(f"Process cleanup failed: {e}") + logger.debug("All processes cleaned up successfully") def _complete_cleanup(self): """Perform complete cleanup of all resources""" diff --git a/src/window/controlcenter/nightlight.py b/src/window/controlcenter/nightlight.py index d1ab0d4..f041cef 100644 --- a/src/window/controlcenter/nightlight.py +++ b/src/window/controlcenter/nightlight.py @@ -1,86 +1,30 @@ -import subprocess - -from fabric.utils import logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label +from services.modus import ( + is_night_light_active, + toggle_night_light, +) from utils.utils import svg_file -class NightLightControl: - """Control night light using hyprsunset""" - - def __init__(self): - self.is_active = False - self._check_initial_state() - - def _check_initial_state(self): - """Check if hyprsunset is currently running""" - try: - result = subprocess.run( - ["pgrep", "-f", "hyprsunset"], capture_output=True, text=True - ) - self.is_active = bool(result.stdout.strip()) - except Exception as e: - logger.warning(f"Failed to check hyprsunset status: {e}") - self.is_active = False - - def toggle(self): - """Toggle night light on/off""" - try: - if self.is_active: - # Turn off night light by killing hyprsunset - subprocess.run(["pkill", "hyprsunset"], check=False) - self.is_active = False - logger.debug("Night light turned off") - else: - # Turn on night light with default temperature (3000K) - subprocess.Popen(["hyprsunset", "-t", "4500"], start_new_session=True) - self.is_active = True - logger.debug("Night light turned on") - return True - except Exception as e: - logger.warning(f"Failed to toggle night light: {e}") - return False - - def set_temperature(self, temperature: int): - """Set night light temperature (1000-6500K)""" - try: - # Kill existing process - subprocess.run(["pkill", "hyprsunset"], check=False) - - # Start with new temperature - subprocess.Popen( - ["hyprsunset", "-t", str(temperature)], start_new_session=True - ) - self.is_active = True - logger.debug(f"Night light temperature set to {temperature}K") - return True - except Exception as e: - logger.warning(f"Failed to set night light temperature: {e}") - return False - - def create_night_light_widget(control_center): """Create night light widget for control center""" - # Initialize night light control - night_light = NightLightControl() + is_active = is_night_light_active() - # Create icon night_light_icon = svg_file( ( "applets/redshift-status-on.svg" - if night_light.is_active + if is_active else "applets/redshift-status-off.svg" ), size=42, ) - # Create status label night_light_status_label = Label( - label="On" if night_light.is_active else "Off", + label="On" if is_active else "Off", name="nightlight-widget-label", style_classes="status-label", max_chars_width=15, @@ -88,19 +32,16 @@ def create_night_light_widget(control_center): h_align="start", ) - def toggle_night_light(*_): - """Toggle night light and update UI""" - if night_light.toggle(): - # Update icon + def toggle_ui(*_): + if toggle_night_light(): + is_active = is_night_light_active() night_light_icon.dynamic_file( "applets/redshift-status-on.svg" - if night_light.is_active + if is_active else "applets/redshift-status-off.svg" ) - # Update status label - night_light_status_label.set_label("On" if night_light.is_active else "Off") + night_light_status_label.set_label("On" if is_active else "Off") - # Create widget box (similar to bluetooth_widget structure) night_light_widget = Box( name="nightlight-widget", orientation="h", @@ -109,7 +50,7 @@ def create_night_light_widget(control_center): Button( name="nightlight-icon-button", child=night_light_icon, - on_clicked=toggle_night_light, + on_clicked=toggle_ui, ), Button( name="nightlight-info-button", @@ -130,7 +71,7 @@ def create_night_light_widget(control_center): night_light_status_label, ], ), - on_clicked=toggle_night_light, + on_clicked=toggle_ui, ), ], ) diff --git a/src/window/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py index 86c9146..3b016d0 100644 --- a/src/window/controlcenter/per_app_volume.py +++ b/src/window/controlcenter/per_app_volume.py @@ -74,6 +74,9 @@ class PerAppVolumeControl(Box): audio_service.connect("stream-removed", self._on_stream_changed) ) + # Track last known app names to avoid unnecessary rebuilds + self._last_app_names = frozenset() + # Initial population self._populate_apps() @@ -84,9 +87,15 @@ class PerAppVolumeControl(Box): """Auto-refresh the application list every 2 seconds""" if self._destroyed: self._refresh_timer = None - return False # Remove GLib source - self._populate_apps() - return True # Continue the timer + return False + if not audio_service: + return True + apps = audio_service.applications or [] + current_names = frozenset(a.name or "" for a in apps) + if current_names != self._last_app_names: + self._last_app_names = current_names + self._populate_apps() + return True def _on_map(self, *_): if self._refresh_timer is None: diff --git a/src/window/controlcenter/wifi.py b/src/window/controlcenter/wifi.py index 5087b31..1a5dce9 100644 --- a/src/window/controlcenter/wifi.py +++ b/src/window/controlcenter/wifi.py @@ -1,5 +1,3 @@ -import subprocess - from fabric.utils import Gdk, GLib, logger from fabric.widgets.box import Box from fabric.widgets.button import Button @@ -12,7 +10,11 @@ from services.network import NetworkClient from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog from shared.widgets.smooth_switch import SmoothSwitch from shared.window.animated_scrollwindow import AnimatedScrollable -from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength +from utils.functions import ( + get_wifi_connecting_icon, + get_wifi_icon_for_strength, + spawn_detached, +) from utils.utils import svg_file @@ -107,7 +109,7 @@ class WifiNetworkSlot(Box): self.dimage.add_style_class("disconnecting") if self.wifi_service and self.wifi_service._device: - subprocess.Popen( + spawn_detached( [ "nmcli", "device", @@ -472,7 +474,7 @@ class WifiConnections(Box): def open_network_settings(self, *_): """Open NetworkManager connection editor""" try: - subprocess.Popen(["nm-connection-editor"], start_new_session=True) + spawn_detached(["nm-connection-editor"]) if self.parent and hasattr(self.parent, "hide_controlcenter"): self.parent.hide_controlcenter() except FileNotFoundError: diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py index 836b0d8..8a84c0a 100644 --- a/src/window/desktop/widget.py +++ b/src/window/desktop/widget.py @@ -35,6 +35,8 @@ executor = ThreadPoolExecutor(max_workers=4) # Global cache for weather data _weather_cache: Dict[str, Tuple[Any, float]] = {} _location_cache: Dict[str, Tuple[float, float, float]] = {} +_WEATHER_CACHE_MAX = 10 +_LOCATION_CACHE_MAX = 10 def http_get_json( @@ -98,6 +100,8 @@ def get_coordinates(city: str) -> Optional[Tuple[float, float]]: try: lat = float(data[0]["lat"]) lon = float(data[0]["lon"]) + while len(_location_cache) >= _LOCATION_CACHE_MAX: + _location_cache.pop(next(iter(_location_cache)), None) _location_cache[cache_key] = (lat, lon, current_time) return lat, lon except (ValueError, KeyError): @@ -191,6 +195,8 @@ def get_weather(callback): formatted_data = format_weather_data(weather_data, location) if formatted_data: + while len(_weather_cache) >= _WEATHER_CACHE_MAX: + _weather_cache.pop(next(iter(_weather_cache)), None) _weather_cache[cache_key] = (formatted_data, current_time) GLib.idle_add(callback, formatted_data) else: @@ -452,6 +458,7 @@ class Calendar(Box): def update_calendar(self): for child in self.calendar_grid.get_children(): self.calendar_grid.remove(child) + child.destroy() self.month_label.set_label(calendar.month_name[self.current_month]) cal = calendar.monthcalendar(self.current_year, self.current_month) for week in cal: diff --git a/src/window/dock/__init__.py b/src/window/dock/__init__.py new file mode 100644 index 0000000..11359d3 --- /dev/null +++ b/src/window/dock/__init__.py @@ -0,0 +1,3 @@ +from .main import Dock + +__all__ = ["Dock"] diff --git a/src/window/dock/animator.py b/src/window/dock/animator.py new file mode 100644 index 0000000..a4f066a --- /dev/null +++ b/src/window/dock/animator.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from fabric.utils import GLib + +from .constants import ANIM_INTERVAL_MS, IDLE_THRESHOLD, LERP_FACTOR +from .layout import DockLayout + +if TYPE_CHECKING: + from .canvas import DockCanvas + + +class DockAnimator: + def __init__(self, canvas: DockCanvas): + self._canvas = canvas + self._timer_id: Optional[int] = None + self._running = False + + def start(self) -> None: + if self._running: + return + self._running = True + self._timer_id = GLib.timeout_add(ANIM_INTERVAL_MS, self._tick) + + def stop(self) -> None: + self._running = False + if self._timer_id is not None: + GLib.source_remove(self._timer_id) + self._timer_id = None + + def _tick(self) -> bool: + if not self._running: + return False + + canvas = self._canvas + items = canvas.model.items + dirty = False + + for item in items: + delta = item.target_scale - item.current_scale + if abs(delta) > IDLE_THRESHOLD: + item.current_scale += delta * LERP_FACTOR + dirty = True + else: + item.current_scale = item.target_scale + + model_w = canvas._canvas_max_width() + model_h = canvas._canvas_height() + DockLayout.compute( + items, + canvas._mouse_x, + canvas._mouse_y, + canvas._base_icon_size(), + model_w, + model_h, + canvas._mouse_inside, + ) + + if ( + model_w != canvas._last_requested_width + or model_h != canvas._last_requested_height + ): + canvas._last_requested_width = model_w + canvas._last_requested_height = model_h + canvas._update_size_request() + + if dirty or canvas._needs_redraw: + canvas._needs_redraw = False + canvas.queue_draw() + + return self._running diff --git a/src/window/dock.py b/src/window/dock/canvas.py similarity index 58% rename from src/window/dock.py rename to src/window/dock/canvas.py index 2cb5436..b8b3140 100644 --- a/src/window/dock.py +++ b/src/window/dock/canvas.py @@ -1,8 +1,6 @@ from __future__ import annotations -import json import math -import subprocess from dataclasses import dataclass from typing import Dict, List, Optional, Tuple @@ -13,80 +11,59 @@ from fabric.utils import ( GdkPixbuf, GLib, Gtk, - exec_shell_command_async, get_desktop_applications, logger, os, - random, - re, ) -from fabric.widgets.revealer import Revealer -from fabric.widgets.wayland import WaylandWindow as Window from gi.repository import Rsvg -from services.config import config, on_config_change -from services.modus import modus_service +from services.config import config +from services.modus import ( + close_window, + get_active_window, + get_clients, + get_modus_service, + launch_app, + open_trash, +) from utils.functions import is_special_workspace_id from utils.icon_resolver import IconResolver -from utils.occlusion import check_occlusion -from utils.utils import svg_file, toml_file +from utils.utils import svg_file -# pinned apps config file path -PINNED_APPS_FILE = toml_file("dock.toml") - -# Animation -LERP_FACTOR = 0.20 # per-frame interpolation speed (0–1) -IDLE_THRESHOLD = 0.0005 # stop redrawing when deltas are smaller than this -ANIM_FPS = 60 -ANIM_INTERVAL_MS = 1000 // ANIM_FPS # 16 ms - -# Magnification (Gaussian) -MAX_SCALE = 2.0 # peak scale at cursor -MIN_SCALE = 1.0 # rest scale -SIGMA_FACTOR = 1.3 # sigma = base_icon_size * SIGMA_FACTOR (wider = smoother wave) - -# Layout -ICON_GAP = 8 # pixels between icons (at scale 1) -BG_PADDING_H = 14 # horizontal padding inside background pill -BG_PADDING_V = 12 # vertical padding (above/below icons inside pill) -CANVAS_TOP_PAD = 28 # extra space above background for magnified icons -INDICATOR_H = 10 # height below pill for running dot -SEPARATOR_WIDTH = 1 # pixel width of separator line -SEPARATOR_GAP = 4 # gap between separator and neighbouring icons - -# Visual style — macOS-inspired frosted glass -BG_RADIUS = 20.0 # background pill corner radius -BG_ALPHA = 0.82 # background fill opacity -BORDER_ALPHA = 0.50 # border stroke opacity -BORDER_WIDTH = 1.0 - -# Icon corner radius as fraction of icon size (squircle) -ICON_CORNER_RADIUS_FACTOR = 0.22 - -# Running dot -INDICATOR_RADIUS = 2.5 -INDICATOR_COLOR = (1.0, 1.0, 1.0, 0.95) - -# Workspace badge -BADGE_FONT_SIZE = 9.0 -BADGE_PADDING = 2 -BADGE_RADIUS = 4.0 -BADGE_BG = (0.0, 0.0, 0.0, 0.70) -BADGE_FG = (1.0, 1.0, 1.0, 1.0) - -SEPARATOR_COLOR = (1.0, 1.0, 1.0, 0.25) +from .animator import DockAnimator +from .constants import ( + BADGE_BG, + BADGE_FG, + BADGE_FONT_SIZE, + BADGE_PADDING, + BADGE_RADIUS, + BG_ALPHA, + BG_PADDING_H, + BG_PADDING_V, + BG_RADIUS, + BORDER_ALPHA, + BORDER_WIDTH, + CANVAS_TOP_PAD, + ICON_GAP, + INDICATOR_COLOR, + INDICATOR_H, + INDICATOR_RADIUS, + MAX_SCALE, + MIN_SCALE, + PINNED_APPS_FILE, + SEPARATOR_COLOR, + SEPARATOR_WIDTH, +) +from .layout import DockLayout @dataclass class DockItem: - """Represents a single icon slot in the dock (pinned or running).""" - app_id: str app_data: object is_pinned: bool = False is_trash: bool = False - # Running state is_running: bool = False instance_address: Optional[str] = None app_class: Optional[str] = None @@ -96,22 +73,18 @@ class DockItem: pixbuf: Optional[GdkPixbuf.Pixbuf] = None tooltip: str = "" - # Animation state (mutated every frame by DockAnimator) current_scale: float = MIN_SCALE target_scale: float = MIN_SCALE - # Set by DockLayout every frame; used by DockHitTest and draw code render_x: float = 0.0 render_y: float = 0.0 render_w: float = 0.0 render_h: float = 0.0 - # Drag-and-drop readiness (for future use) drag_index: int = 0 is_dragging: bool = False def hit(self, mx: float, my: float) -> bool: - """Return True if (mx, my) falls within this item's render rectangle.""" return ( self.render_x <= mx <= self.render_x + self.render_w and self.render_y <= my <= self.render_y + self.render_h @@ -119,8 +92,6 @@ class DockItem: class DockModel: - """Ordered list of DockItem objects for the dock.""" - def __init__(self): self.items: List[DockItem] = [] @@ -149,162 +120,25 @@ class DockModel: return any(i.is_pinned for i in self.items) -class DockLayout: - """Computes gaussian target scales and push-apart render rects per frame.""" - - @staticmethod - def compute( - items: List[DockItem], - mouse_x: float, - mouse_y: float, - base_icon_size: int, - canvas_w: int, - canvas_h: int, - mouse_inside: bool, - ) -> None: - if not items: - return - - sigma = base_icon_size * SIGMA_FACTOR - amplitude = MAX_SCALE - MIN_SCALE - - n = len(items) - # Estimate centres assuming uniform icon size (fast approximation) - base_w = base_icon_size + ICON_GAP - total_base = n * base_icon_size + max(n - 1, 0) * ICON_GAP - start_x = (canvas_w - total_base) / 2.0 - - for i, item in enumerate(items): - est_cx = start_x + i * base_w + base_icon_size / 2.0 - - if mouse_inside: - dist = abs(est_cx - mouse_x) - target = MIN_SCALE + amplitude * math.exp( - -(dist * dist) / (2.0 * sigma * sigma) - ) - else: - target = MIN_SCALE - - item.target_scale = target - - bg_h = base_icon_size + 2 * BG_PADDING_V - bg_y = canvas_h - INDICATOR_H - bg_h - baseline_y = bg_y + BG_PADDING_V + base_icon_size - - rendered_widths = [base_icon_size * item.current_scale for item in items] - total_w = sum(rendered_widths) + max(n - 1, 0) * ICON_GAP - cursor_x = (canvas_w - total_w) / 2.0 - - for i, item in enumerate(items): - iw = rendered_widths[i] - ih = base_icon_size * item.current_scale - ix = cursor_x - iy = baseline_y - ih - item.render_x = ix - item.render_y = iy - item.render_w = iw - item.render_h = ih - item.drag_index = i - cursor_x += iw + ICON_GAP - - @staticmethod - def background_rect( - items: List[DockItem], - base_icon_size: int, - canvas_w: int, - canvas_h: int, - ) -> Tuple[float, float, float, float]: - bg_h = base_icon_size + 2 * BG_PADDING_V - bg_y = canvas_h - INDICATOR_H - bg_h - - if items: - min_x = min(item.render_x for item in items) - max_x = max(item.render_x + item.render_w for item in items) - bg_x = min_x - BG_PADDING_H - bg_w = (max_x - min_x) + 2 * BG_PADDING_H - else: - bg_x = BG_PADDING_H - bg_w = canvas_w - 2 * BG_PADDING_H - - return bg_x, bg_y, bg_w, bg_h - - -class DockAnimator: - """Drives the 60fps animation loop via GLib timeout.""" - - def __init__(self, canvas: "DockCanvas"): - self._canvas = canvas - self._timer_id: Optional[int] = None - self._running = False - - def start(self) -> None: - if self._running: - return - self._running = True - self._timer_id = GLib.timeout_add(ANIM_INTERVAL_MS, self._tick) - - def stop(self) -> None: - self._running = False - if self._timer_id is not None: - GLib.source_remove(self._timer_id) - self._timer_id = None - - def _tick(self) -> bool: - if not self._running: - return False - - canvas = self._canvas - items = canvas.model.items - dirty = False - - for item in items: - delta = item.target_scale - item.current_scale - if abs(delta) > IDLE_THRESHOLD: - item.current_scale += delta * LERP_FACTOR - dirty = True - else: - item.current_scale = item.target_scale - - w = canvas.get_allocated_width() - h = canvas.get_allocated_height() - DockLayout.compute( - items, - canvas._mouse_x, - canvas._mouse_y, - canvas._base_icon_size(), - w if w > 1 else canvas._canvas_max_width(), - h if h > 1 else canvas._canvas_height(), - canvas._mouse_inside, - ) - - if dirty or canvas._needs_redraw: - canvas._needs_redraw = False - canvas.queue_draw() - - return self._running # keep the timer alive - - class DockHitTest: - """Maps cursor coords to the DockItem under it via render rects.""" - @staticmethod def find_item(items: List[DockItem], mx: float, my: float) -> Optional[DockItem]: - # Search in reverse so topmost (largest) icons take priority for item in reversed(items): if item.hit(mx, my): return item return None -class DockCanvas(Gtk.DrawingArea): - """GTK drawing area with cairo rendering and full dock logic.""" +_PIXBUF_CACHE_MAX = 64 - def __init__(self, parent_window: Window): + +class DockCanvas(Gtk.DrawingArea): + def __init__(self, parent_window): super().__init__() self._parent = parent_window self.icon_resolver = IconResolver() - self._hyprland_connection = modus_service._hyprland_connection + self._hyprland_connection = get_modus_service()._hyprland_connection self.model = DockModel() self._pixbuf_cache: Dict[Tuple[str, int], Optional[GdkPixbuf.Pixbuf]] = {} @@ -317,6 +151,14 @@ class DockCanvas(Gtk.DrawingArea): self._focused_address: str = "" self._dock_update_timer: Optional[int] = None self._hyprland_event_handlers: List = [] + self._last_requested_width: int = 0 + self._last_requested_height: int = 0 + self._modus_ws_handler: int | None = None + + self._desktop_apps: list = [] + self._trash_pixbuf: Optional[GdkPixbuf.Pixbuf] = None + self._cached_trash_full: Optional[bool] = None + self._cached_trash_size: int = 0 self.pinned_apps: List = self._read_pinned_apps() self.menu = Gtk.Menu() @@ -329,9 +171,7 @@ class DockCanvas(Gtk.DrawingArea): self.show() - # signal handlers + rgba visual def _configure_widget(self) -> None: - # Allow mouse events self.add_events( Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK @@ -357,18 +197,29 @@ class DockCanvas(Gtk.DrawingArea): if visual: self.set_visual(visual) - # icon size from config def _base_icon_size(self) -> int: return int(config().get("dock_icon_size", 52)) - # canvas height with room for magnification + def _icon_cache_size(self) -> int: + return max(64, self._base_icon_size()) + + def _get_desktop_apps(self) -> list: + if not self._desktop_apps: + try: + self._desktop_apps = get_desktop_applications(include_hidden=False) + except Exception: + self._desktop_apps = [] + return self._desktop_apps + + def _refresh_desktop_apps(self) -> None: + self._desktop_apps = [] + def _canvas_height(self) -> int: size = self._base_icon_size() return int( size * MAX_SCALE + CANVAS_TOP_PAD + 2 * BG_PADDING_V + INDICATOR_H + 4 ) - # worst-case width for max zoom (window never resizes during animation) def _canvas_max_width(self) -> int: n = len(self.model.items) if n == 0: @@ -378,69 +229,55 @@ class DockCanvas(Gtk.DrawingArea): n * size * MAX_SCALE + max(n - 1, 0) * ICON_GAP + 2 * BG_PADDING_H + 40 ) - # same as max — pre-allocated so canvas stays fixed def _canvas_min_width(self) -> int: return self._canvas_max_width() - # update gtk size request def _update_size_request(self) -> None: w = self._canvas_max_width() h = self._canvas_height() self.set_size_request(w, h) self.queue_resize() - # mark dirty on resize def _on_size_allocate(self, widget, allocation) -> None: self._needs_redraw = True - # gtk height-for-width geometry def do_get_preferred_height(self): h = self._canvas_height() return h, h - # gtk width-for-height geometry def do_get_preferred_width(self): w = self._canvas_max_width() return w, w - # lookup or cache pixbuf by icon name - # lookup or cache pixbuf by icon name def _get_pixbuf(self, icon_name: str, size: int) -> Optional[GdkPixbuf.Pixbuf]: key = (icon_name, size) if key not in self._pixbuf_cache: + while len(self._pixbuf_cache) >= _PIXBUF_CACHE_MAX: + self._pixbuf_cache.pop(next(iter(self._pixbuf_cache)), None) self._pixbuf_cache[key] = self.icon_resolver.get_icon_pixbuf( icon_name, size ) return self._pixbuf_cache[key] - # clear pixbuf cache (e.g. on icon size change) def _clear_pixbuf_cache(self) -> None: self._pixbuf_cache.clear() - # resolve icon from app data with fallback def _get_app_icon_pixbuf( self, app_data, app=None, size: Optional[int] = None ) -> Optional[GdkPixbuf.Pixbuf]: if size is None: size = self._base_icon_size() - if app: - try: - pb = app.get_icon_pixbuf(size) - if pb: - return pb - except Exception: - pass - - icon_name = "" - if isinstance(app_data, dict): + icon_name = None + if app and hasattr(app, "icon_name") and app.icon_name: + icon_name = app.icon_name + elif isinstance(app_data, dict): icon_name = app_data.get("window_class") or app_data.get("name") or "" elif isinstance(app_data, str): icon_name = app_data return self._get_pixbuf(icon_name or "application-x-executable", size) - # rebuild dock items from pinned apps + running clients def _rebuild_model(self) -> None: old_scales: Dict[str, float] = {} for item in self.model.items: @@ -455,98 +292,72 @@ class DockCanvas(Gtk.DrawingArea): logger.error(f"[DockCanvas] Error fetching clients: {e}") clients = [] - try: - desktop_apps = get_desktop_applications(include_hidden=False) - except Exception: - desktop_apps = [] + desktop_apps = self._get_desktop_apps() + size = self._icon_cache_size() - new_items: List[DockItem] = [] - size = int( - self._base_icon_size() * MAX_SCALE - ) # load hi-res so magnified icons stay sharp - - running_classes_lower = { - (c.get("class", "") or c.get("title", "")).lower() + active_clients = { + c.get("address"): c for c in clients if not c.get("hidden", False) and self._should_show_app_instance(c) } + running_classes_lower = { + (c.get("class", "") or c.get("title", "")).lower() + for c in active_clients.values() + } + + pinned_ids_lower = { + self._get_app_identifier(a).lower() for a in self.pinned_apps + } + + new_items: List[DockItem] = [] + for app_data in self.pinned_apps: app_id = self._get_app_identifier(app_data) if not app_id: continue - app = self._find_desktop_app(app_data, desktop_apps) - pixbuf = self._get_app_icon_pixbuf(app_data, app, size) - - display_name = ( - app.display_name - if app - else ( - app_data.get("display_name", app_id) - if isinstance(app_data, dict) - else app_id + existing = self.model.get_by_id(app_id) + if existing and existing.is_pinned: + item = existing + currently_running = app_id.lower() in running_classes_lower + item.is_running = currently_running + if not currently_running: + item.instance_address = None + item.client_data = None + else: + app = self._find_desktop_app(app_data, desktop_apps) + pixbuf = self._get_app_icon_pixbuf(app_data, app, size) + display_name = ( + app.display_name + if app + else ( + app_data.get("display_name", app_id) + if isinstance(app_data, dict) + else app_id + ) + ) + item = DockItem( + app_id=app_id, + app_data=app_data, + is_pinned=True, + pixbuf=pixbuf, + tooltip=display_name, ) - ) - is_running = app_id.lower() in running_classes_lower - - item = DockItem( - app_id=app_id, - app_data=app_data, - is_pinned=True, - pixbuf=pixbuf, - tooltip=display_name, - is_running=is_running, - ) key = app_id item.current_scale = old_scales.get(key, MIN_SCALE) new_items.append(item) - trash_has_files = self._trash_has_files() - trash_svg = "trash-full.svg" if trash_has_files else "trash-empty.svg" - try: - svg_w = svg_file(f"misc/{trash_svg}") - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) - cr = cairo.Context(surface) - rect = Rsvg.Rectangle() - rect.x = rect.y = 0 - rect.width = size - rect.height = size - svg_w._handle.set_dpi(160) - svg_w._handle.render_document(cr, rect) - trash_pixbuf = Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size) - del svg_w - except Exception: - trash_pixbuf = self._get_pixbuf("user-trash", size) - trash_item = DockItem( - app_id="trash", - app_data="trash", - is_pinned=True, - is_trash=True, - pixbuf=trash_pixbuf, - tooltip="Trash", - ) - trash_item.current_scale = old_scales.get("trash", MIN_SCALE) - new_items.append(trash_item) - - pinned_ids_lower = { - self._get_app_identifier(a).lower() for a in self.pinned_apps - } - - for client in clients: - if client.get("hidden", False): - continue - if not self._should_show_app_instance(client): - continue + self._update_trash_item(new_items, old_scales, size) + for client in active_clients.values(): instance_address = client.get("address", "") app_class = client.get("class", "") or client.get("title", "") if not instance_address or not app_class: continue if app_class.lower() in pinned_ids_lower: - # Update running state on the pinned item and store client data pinned = next( (i for i in new_items if i.app_id.lower() == app_class.lower()), None, @@ -559,24 +370,34 @@ class DockCanvas(Gtk.DrawingArea): pinned.workspace_id = self._get_workspace_id(client) continue - app = self._find_desktop_app(app_class, desktop_apps) - pixbuf = self._get_app_icon_pixbuf(app_class, app, size) - tooltip = client.get("title", app_class) - if tooltip != app_class: - tooltip = f"{app_class}: {tooltip}" + existing = self.model.get_by_address(instance_address) + if existing and not existing.is_pinned and not existing.is_trash: + item = existing + tooltip = client.get("title", app_class) + item.tooltip = ( + f"{app_class}: {tooltip}" if tooltip != app_class else app_class + ) + item.client_data = client + item.workspace_id = self._get_workspace_id(client) + else: + app = self._find_desktop_app(app_class, desktop_apps) + pixbuf = self._get_app_icon_pixbuf(app_class, app, size) + tooltip = client.get("title", app_class) + if tooltip != app_class: + tooltip = f"{app_class}: {tooltip}" + item = DockItem( + app_id=app_class, + app_data=app_class, + is_pinned=False, + is_running=True, + instance_address=instance_address, + app_class=app_class, + client_data=client, + workspace_id=self._get_workspace_id(client), + pixbuf=pixbuf, + tooltip=tooltip, + ) - item = DockItem( - app_id=app_class, - app_data=app_class, - is_pinned=False, - is_running=True, - instance_address=instance_address, - app_class=app_class, - client_data=client, - workspace_id=self._get_workspace_id(client), - pixbuf=pixbuf, - tooltip=tooltip, - ) key = instance_address item.current_scale = old_scales.get(key, MIN_SCALE) new_items.append(item) @@ -584,27 +405,59 @@ class DockCanvas(Gtk.DrawingArea): self.model.items = new_items self._needs_redraw = True - DockLayout.compute( - new_items, - self._mouse_x, - self._mouse_y, - self._base_icon_size(), - self._canvas_min_width(), - self._canvas_height(), - False, - ) + def _update_trash_item( + self, + new_items: List[DockItem], + old_scales: Dict[str, float], + size: int, + ) -> None: + trash_full = self._trash_has_files() + if ( + self._trash_pixbuf is None + or trash_full != self._cached_trash_full + or size != self._cached_trash_size + ): + self._cached_trash_full = trash_full + self._cached_trash_size = size + trash_svg = "trash-full.svg" if trash_full else "trash-empty.svg" + try: + svg_w = svg_file(f"misc/{trash_svg}") + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, size, size) + cr = cairo.Context(surface) + rect = Rsvg.Rectangle() + rect.x = rect.y = 0 + rect.width = size + rect.height = size + svg_w._handle.set_dpi(160) + svg_w._handle.render_document(cr, rect) + self._trash_pixbuf = Gdk.pixbuf_get_from_surface( + surface, 0, 0, size, size + ) + del svg_w + except Exception: + self._trash_pixbuf = self._get_pixbuf("user-trash", size) - self._update_size_request() + existing = self.model.get_by_id("trash") + if existing and existing.is_trash: + item = existing + item.pixbuf = self._trash_pixbuf + else: + item = DockItem( + app_id="trash", + app_data="trash", + is_pinned=True, + is_trash=True, + pixbuf=self._trash_pixbuf, + tooltip="Trash", + ) + item.current_scale = old_scales.get("trash", MIN_SCALE) + new_items.append(item) - # connect hyprland events for auto-rebuild def setup_app_monitoring(self) -> None: events = [ "event::openwindow", "event::closewindow", - "event::activewindow", - "event::windowtitle", "event::movewindow", - "event::workspace", ] for event in events: handler_id = self._hyprland_connection.connect( @@ -612,15 +465,75 @@ class DockCanvas(Gtk.DrawingArea): ) self._hyprland_event_handlers.append(handler_id) + self._hyprland_event_handlers.append( + self._hyprland_connection.connect( + "event::activewindow", self._on_active_window_changed + ) + ) + + self._hyprland_event_handlers.append( + self._hyprland_connection.connect( + "event::windowtitle", self._on_window_title_changed + ) + ) + + self._modus_ws_handler = get_modus_service().connect( + "current-workspace-changed", self._on_modus_workspace_changed + ) + GLib.idle_add(self._rebuild_model) - # debounce rapid hyprland events (50ms window) + def _on_modus_workspace_changed(self, service, value) -> None: + try: + clients = self._get_clients() + except Exception: + return + client_map = {c.get("address"): c for c in clients if c.get("address")} + for item in self.model.items: + if item.instance_address and item.instance_address in client_map: + item.workspace_id = self._get_workspace_id( + client_map[item.instance_address] + ) + self._needs_redraw = True + self.queue_draw() + + def _on_active_window_changed(self, *args) -> None: + try: + focused = get_active_window() + self._focused_address = (focused or {}).get("address", "") + except Exception: + self._focused_address = "" + self._needs_redraw = True + self.queue_draw() + + def _on_window_title_changed(self, hyprland, signal) -> None: + try: + address = signal.data[0] + if not address: + return + for item in self.model.items: + if item.instance_address == address: + clients = self._get_clients() + client = next( + (c for c in clients if c.get("address") == address), None + ) + if client: + title = client.get("title", "") + app_class = item.app_class or "" + item.tooltip = ( + f"{app_class}: {title}" if title != app_class else app_class + ) + break + except Exception: + pass + self._needs_redraw = True + self.queue_draw() + def debounced_update_dock_apps(self) -> None: if self._dock_update_timer: GLib.source_remove(self._dock_update_timer) self._dock_update_timer = GLib.timeout_add(50, self._do_update) - # actually run the rebuild def _do_update(self) -> bool: self._dock_update_timer = None try: @@ -629,19 +542,16 @@ class DockCanvas(Gtk.DrawingArea): logger.error(f"[DockCanvas] Error in update: {e}") return False - # refresh icons and layout (e.g. config change) def update_icon_size(self) -> None: self._clear_pixbuf_cache() self._rebuild_model() - # main cairo draw entry point def _on_draw(self, widget, cr: cairo.Context) -> bool: w = self.get_allocated_width() h = self.get_allocated_height() items = self.model.items base_size = self._base_icon_size() - # Clear cr.set_operator(cairo.OPERATOR_CLEAR) cr.paint() cr.set_operator(cairo.OPERATOR_OVER) @@ -661,7 +571,6 @@ class DockCanvas(Gtk.DrawingArea): return True - # draw a rounded rectangle path def _rounded_rect( self, cr: cairo.Context, @@ -678,7 +587,6 @@ class DockCanvas(Gtk.DrawingArea): cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) cr.close_path() - # draw the frosted-glass pill background def _draw_background( self, cr: cairo.Context, @@ -714,9 +622,9 @@ class DockCanvas(Gtk.DrawingArea): self._rounded_rect(cr, x, y, w, h, r) cr.clip() spec = cairo.LinearGradient(x, y, x, y + h * 0.55) - spec.add_color_stop_rgba(0.00, 1.0, 1.0, 1.0, 0.22) # bright edge + spec.add_color_stop_rgba(0.00, 1.0, 1.0, 1.0, 0.22) spec.add_color_stop_rgba(0.30, 1.0, 1.0, 1.0, 0.08) - spec.add_color_stop_rgba(1.00, 0.0, 0.0, 0.0, 0.08) # slight darken at bottom + spec.add_color_stop_rgba(1.00, 0.0, 0.0, 0.0, 0.08) cr.set_source(spec) cr.paint() cr.restore() @@ -731,7 +639,6 @@ class DockCanvas(Gtk.DrawingArea): cr.stroke() cr.restore() - # vertical separator between pinned and running sections def _draw_separator( self, cr: cairo.Context, @@ -762,7 +669,6 @@ class DockCanvas(Gtk.DrawingArea): cr.stroke() cr.restore() - # render a single dock icon + active dot + badge def _draw_item( self, cr: cairo.Context, @@ -780,7 +686,6 @@ class DockCanvas(Gtk.DrawingArea): if item.pixbuf: cr.save() - # Scale the pixbuf to render size pb_w = item.pixbuf.get_width() pb_h = item.pixbuf.get_height() if pb_w > 0 and pb_h > 0: @@ -792,7 +697,6 @@ class DockCanvas(Gtk.DrawingArea): cr.paint() cr.restore() else: - # Fallback: draw a coloured placeholder cr.save() cr.set_source_rgba(0.4, 0.4, 0.5, 0.8) cr.rectangle(ix, iy, iw, ih) @@ -811,7 +715,6 @@ class DockCanvas(Gtk.DrawingArea): if item.workspace_id is not None and not item.is_pinned: self._draw_workspace_badge(cr, ix, iy, iw, ih, str(item.workspace_id)) - # small badge showing the workspace number def _draw_workspace_badge( self, cr: cairo.Context, @@ -836,14 +739,12 @@ class DockCanvas(Gtk.DrawingArea): self._rounded_rect(cr, bx, by, bw, bh, BADGE_RADIUS) cr.fill() - # Text cr.set_source_rgba(*BADGE_FG) cr.move_to(bx + BADGE_PADDING - x_bearing, by + BADGE_PADDING - y_bearing) cr.show_text(text) cr.restore() - # track mouse position and update hover/tooltip def _on_motion(self, widget, event: Gdk.EventMotion) -> bool: self._mouse_x = event.x self._mouse_y = event.y @@ -875,7 +776,6 @@ class DockCanvas(Gtk.DrawingArea): return False - # cursor enters dock canvas def _on_enter(self, widget, event: Gdk.EventCrossing) -> bool: self._mouse_inside = False self._parent.on_hover_enter() @@ -883,7 +783,6 @@ class DockCanvas(Gtk.DrawingArea): self.animator.start() return False - # cursor leaves dock canvas def _on_leave(self, widget, event: Gdk.EventCrossing) -> bool: self._mouse_inside = False self._mouse_x = -9999.0 @@ -892,7 +791,6 @@ class DockCanvas(Gtk.DrawingArea): self._parent.on_hover_leave() return False - # handle click on dock items (launch, focus, menu) def _on_button_press(self, widget, event: Gdk.EventButton) -> bool: item = DockHitTest.find_item(self.model.items, event.x, event.y) if item is None: @@ -919,7 +817,6 @@ class DockCanvas(Gtk.DrawingArea): return True - # launch/menu for pinned-only apps def _handle_pinned_click(self, event: Gdk.EventButton, item: DockItem) -> None: if event.button == 1: self._launch_app(item.app_data) @@ -929,7 +826,6 @@ class DockCanvas(Gtk.DrawingArea): self.show_menu(item.app_id) self.menu.popup_at_pointer(event) - # focus window or launch for running instances def _handle_instance_click(self, event: Gdk.EventButton, item: DockItem) -> None: if event.button == 1: if item.instance_address: @@ -950,7 +846,6 @@ class DockCanvas(Gtk.DrawingArea): self.show_menu(app_id, instance_address=item.instance_address) self.menu.popup_at_pointer(event) - # build and show the right-click context menu def show_menu( self, app_id: str, @@ -983,16 +878,9 @@ class DockCanvas(Gtk.DrawingArea): self.menu.show_all() - # close a running window by hyprland address def _close_running_app(self, instance_address: str) -> None: - try: - self._hyprland_connection.send_command( - f"dispatch closewindow address:{instance_address}" - ) - except Exception as e: - logger.error(f"[DockCanvas] Error closing window: {e}") + close_window(instance_address) - # check if trash directory has files def _trash_has_files(self) -> bool: try: trash_path = os.path.expanduser("~/.local/share/Trash/files") @@ -1000,24 +888,9 @@ class DockCanvas(Gtk.DrawingArea): except Exception: return False - # open trash in the default file manager def _handle_trash_click(self) -> None: - try: - trash_path = os.path.expanduser("~/.local/share/Trash/files") - for fm in ["nautilus", "dolphin", "thunar", "nemo", "caja", "pcmanfm"]: - 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"[DockCanvas] Error opening trash: {e}") + open_trash() - # load pinned apps from dock.toml def _read_pinned_apps(self) -> list: try: if os.path.exists(PINNED_APPS_FILE): @@ -1028,7 +901,6 @@ class DockCanvas(Gtk.DrawingArea): logger.error(f"[DockCanvas] Failed to read pinned apps: {e}") return [] - # save pinned apps to dock.toml def _write_pinned_apps(self) -> None: try: doc = tomlkit.document() @@ -1047,13 +919,11 @@ class DockCanvas(Gtk.DrawingArea): except Exception as e: logger.error(f"[DockCanvas] Failed to write pinned apps: {e}") - # add an app to the pinned list def _pin_app(self, app_class: str) -> bool: 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 = self._find_desktop_app(app_class, self._get_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, @@ -1070,7 +940,6 @@ class DockCanvas(Gtk.DrawingArea): self._rebuild_model() return True - # remove an app from the pinned list def _unpin_app(self, app_identifier: str) -> bool: to_remove = [ i @@ -1085,11 +954,9 @@ class DockCanvas(Gtk.DrawingArea): return True return False - # check if an app is already pinned def _is_app_pinned(self, app_class: str) -> bool: return any(self._matches_app_identifier(a, app_class) for a in self.pinned_apps) - # compare a pinned entry with an identifier def _matches_app_identifier(self, pinned_app, app_identifier: str) -> bool: if not app_identifier: return False @@ -1104,13 +971,11 @@ class DockCanvas(Gtk.DrawingArea): isinstance(pinned_app, str) and pinned_app.lower() == app_identifier.lower() ) - # extract identifier from app data def _get_app_identifier(self, app_data) -> str: if isinstance(app_data, dict): return app_data.get("name", "") or app_data.get("window_class", "") return str(app_data) if app_data else "" - # launch an application via hyprctl def _launch_app(self, app_info) -> None: try: command_line = "" @@ -1124,15 +989,12 @@ class DockCanvas(Gtk.DrawingArea): command_line = app_info if not command_line: - desktop_apps = get_desktop_applications(include_hidden=False) - app = self._find_desktop_app(app_info, desktop_apps) + app = self._find_desktop_app(app_info, self._get_desktop_apps()) if app: command_line = app.command_line if command_line: - cleaned = re.sub(r"%\w+", "", command_line).strip() - final = f"hyprctl dispatch 'hl.dsp.exec_cmd([[uwsm app -- {cleaned}]])'" - exec_shell_command_async(final) + launch_app(command_line) elif hasattr(app_info, "launch"): app_info.launch() else: @@ -1142,7 +1004,6 @@ class DockCanvas(Gtk.DrawingArea): except Exception as e: logger.error(f"[DockCanvas] Failed to launch app: {e}") - # find desktop app entry matching app_info def _find_desktop_app(self, app_info, desktop_apps): if not app_info: return None @@ -1173,13 +1034,11 @@ class DockCanvas(Gtk.DrawingArea): if len(part) > 2 and part not in expanded: expanded.append(part) - # Priority 1: window_class exact for app in desktop_apps: ac = getattr(app, "window_class", None) if ac and ac.lower() in search_terms: return app - # Priority 2: name/display_name exact for app in desktop_apps: names = [ getattr(app, "name", None), @@ -1188,7 +1047,6 @@ class DockCanvas(Gtk.DrawingArea): if any(n and n.lower() in expanded for n in names): return app - # Priority 3: executable basename for app in desktop_apps: if app.executable: exe = os.path.basename(app.executable).lower() @@ -1203,7 +1061,6 @@ class DockCanvas(Gtk.DrawingArea): continue return app - # Priority 4: fuzzy containment for app in desktop_apps: at = [ getattr(app, "name", ""), @@ -1219,29 +1076,12 @@ class DockCanvas(Gtk.DrawingArea): return None - # fetch all hyprland windows def _get_clients(self) -> list: - try: - reply = self._hyprland_connection.send_command("j/clients").reply - if not reply: - return [] - return json.loads(reply.decode("utf-8")) - except Exception as e: - logger.error(f"[DockCanvas] Error getting clients: {e}") - return [] + return get_clients() - # fetch the currently focused window def _get_focused_window(self) -> Optional[dict]: - try: - reply = self._hyprland_connection.send_command("j/activewindow").reply - if not reply: - return None - return json.loads(reply.decode("utf-8")) - except Exception as e: - logger.error(f"[DockCanvas] Error getting focused window: {e}") - return None + return get_active_window() - # extract workspace id from client data def _get_workspace_id(self, client: dict) -> Optional[int]: ws = client.get("workspace", {}) if isinstance(ws, dict): @@ -1253,14 +1093,12 @@ class DockCanvas(Gtk.DrawingArea): return None return None - # hide apps on special workspaces if configured def _should_show_app_instance(self, client: dict) -> bool: if not config().get("dock_hide_special_workspace_apps", True): return True ws_id = self._get_workspace_id(client) return False if ws_id is None else not is_special_workspace_id(ws_id) - # clean up animator, timers, and hyprland handlers def destroy(self) -> None: self.animator.stop() @@ -1275,170 +1113,14 @@ class DockCanvas(Gtk.DrawingArea): pass self._hyprland_event_handlers.clear() + if self._modus_ws_handler is not None: + try: + get_modus_service().disconnect(self._modus_ws_handler) + except Exception: + pass + self._modus_ws_handler = None + if self.menu: self.menu.destroy() super().destroy() - - -class Dock(Window): - """Top-level wayland window that wraps DockCanvas with reveal + occlusion.""" - - def __init__(self): - super().__init__( - layer="top", - anchor="bottom center", - exclusivity="none", - title="modus-dock", - name="dock", - ) - - screen = self.get_screen() - if screen: - visual = screen.get_rgba_visual() - if visual: - self.set_visual(visual) - self.set_app_paintable(True) - - self.canvas = DockCanvas(self) - - from fabric.widgets.box import Box - from fabric.widgets.eventbox import EventBox - - canvas_box = Box( - name="dock-canvas-box", - children=[self.canvas], - ) - - self.revealer = Revealer( - child=canvas_box, - transition_duration=200, - transition_type="slide-up", - ) - - hover_strip = Box( - name="dock-hover-strip", - ) - - outer_box = Box( - name="dock-outer-box", - orientation="v", - children=[self.revealer, hover_strip], - ) - - self.children = EventBox( - events=["enter-notify", "leave-notify"], - child=outer_box, - on_enter_notify_event=lambda *_: self.on_hover_enter(), - on_leave_notify_event=lambda *_: self.on_hover_leave(), - ) - - self.dock_height = 100 - self.is_hovered = False - self.hide_ticket = 0 - self._startup_grace = True # block occlusion hiding for first 2s - - self.revealer.set_reveal_child(True) - self.show_all() - - on_config_change(self._on_config_change) - - if config().get("dock_auto_hide", True): - GLib.timeout_add(2000, self._start_occlusion_monitoring) - else: - self._occlusion_timer_id = None - - # start occlusion checks after startup grace period - def _start_occlusion_monitoring(self) -> bool: - self._startup_grace = False - self.setup_occlusion_monitoring() - return False # one-shot timer - - # respond to config changes (visibility, icon size, auto-hide) - def _on_config_change(self, new_config, old_config) -> None: - if config().has_changed("dock_enabled", old_config): - self._update_visibility() - - if config().has_changed("dock_icon_size", old_config): - self.canvas.update_icon_size() - - if config().has_changed("dock_auto_hide", old_config): - if new_config.get("dock_auto_hide", True): - self.setup_occlusion_monitoring() - else: - if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: - GLib.source_remove(self._occlusion_timer_id) - self._occlusion_timer_id = None - self.revealer.set_reveal_child(True) - - if config().has_changed("dock_hide_special_workspace_apps", old_config): - self.canvas._rebuild_model() - - # show/hide dock based on config - def _update_visibility(self) -> None: - if config().get("dock_enabled", True): - self.show() - self.revealer.set_reveal_child(True) - else: - self.hide() - - # show dock when cursor enters its area - def on_hover_enter(self) -> None: - self.is_hovered = True - self.hide_ticket = random.getrandbits(32) - self.revealer.set_reveal_child(True) - - # hide dock after delay if occluded - def on_hover_leave(self) -> None: - self.is_hovered = False - ticket = random.getrandbits(32) - self.hide_ticket = ticket - - def delayed_hide(t): - if t == self.hide_ticket and not self.is_hovered: - if config().get("dock_auto_hide", True): - is_occ = config().get( - "dock_always_occluded", False - ) or check_occlusion(("bottom", self.dock_height)) - if is_occ: - self.revealer.set_reveal_child(False) - return False - - GLib.timeout_add(500, delayed_hide, ticket) - - # periodic check for windows overlapping the dock - def setup_occlusion_monitoring(self) -> None: - if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: - GLib.source_remove(self._occlusion_timer_id) - self._occlusion_timer_id = None - - def check_dock_occlusion() -> bool: - try: - if config().get("dock_always_occluded", False): - is_occ = True - else: - is_occ = check_occlusion(("bottom", self.dock_height)) - - if is_occ and not self.is_hovered and self.revealer.get_reveal_child(): - self.revealer.set_reveal_child(False) - elif not is_occ and not self.revealer.get_reveal_child(): - self.revealer.set_reveal_child(True) - elif is_occ and self.is_hovered: - if not self.revealer.get_reveal_child(): - self.revealer.set_reveal_child(True) - except Exception as e: - logger.error(f"[Dock] Occlusion check error: {e}") - return True - - self._occlusion_timer_id = GLib.timeout_add(300, check_dock_occlusion) - - # clean up timers and canvas - def destroy(self) -> None: - if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: - GLib.source_remove(self._occlusion_timer_id) - self._occlusion_timer_id = None - - if hasattr(self, "canvas") and self.canvas: - self.canvas.destroy() - - super().destroy() diff --git a/src/window/dock/constants.py b/src/window/dock/constants.py new file mode 100644 index 0000000..30e489a --- /dev/null +++ b/src/window/dock/constants.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from utils.utils import toml_file + +PINNED_APPS_FILE = toml_file("dock.toml") + +LERP_FACTOR = 0.20 +IDLE_THRESHOLD = 0.0005 +ANIM_FPS = 60 +ANIM_INTERVAL_MS = 1000 // ANIM_FPS + +MAX_SCALE = 2.0 +MIN_SCALE = 1.0 +SIGMA_FACTOR = 1.3 + +ICON_GAP = 8 +BG_PADDING_H = 14 +BG_PADDING_V = 12 +CANVAS_TOP_PAD = 28 +INDICATOR_H = 10 +SEPARATOR_WIDTH = 1 +SEPARATOR_GAP = 4 + +BG_RADIUS = 20.0 +BG_ALPHA = 0.82 +BORDER_ALPHA = 0.50 +BORDER_WIDTH = 1.0 + +ICON_CORNER_RADIUS_FACTOR = 0.22 + +INDICATOR_RADIUS = 2.5 +INDICATOR_COLOR = (1.0, 1.0, 1.0, 0.95) + +BADGE_FONT_SIZE = 9.0 +BADGE_PADDING = 2 +BADGE_RADIUS = 4.0 +BADGE_BG = (0.0, 0.0, 0.0, 0.70) +BADGE_FG = (1.0, 1.0, 1.0, 1.0) + +SEPARATOR_COLOR = (1.0, 1.0, 1.0, 0.25) diff --git a/src/window/dock/layout.py b/src/window/dock/layout.py new file mode 100644 index 0000000..405d20d --- /dev/null +++ b/src/window/dock/layout.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, List, Tuple + +from .constants import ( + BG_PADDING_H, + BG_PADDING_V, + ICON_GAP, + INDICATOR_H, + MAX_SCALE, + MIN_SCALE, + SIGMA_FACTOR, +) + +if TYPE_CHECKING: + from .canvas import DockItem + + +class DockLayout: + @staticmethod + def compute( + items: List[DockItem], + mouse_x: float, + mouse_y: float, + base_icon_size: int, + canvas_w: int, + canvas_h: int, + mouse_inside: bool, + ) -> None: + if not items: + return + + sigma = base_icon_size * SIGMA_FACTOR + amplitude = MAX_SCALE - MIN_SCALE + + n = len(items) + base_w = base_icon_size + ICON_GAP + total_base = n * base_icon_size + max(n - 1, 0) * ICON_GAP + start_x = (canvas_w - total_base) / 2.0 + + for i, item in enumerate(items): + est_cx = start_x + i * base_w + base_icon_size / 2.0 + + if mouse_inside: + dist = abs(est_cx - mouse_x) + target = MIN_SCALE + amplitude * math.exp( + -(dist * dist) / (2.0 * sigma * sigma) + ) + else: + target = MIN_SCALE + + item.target_scale = target + + bg_h = base_icon_size + 2 * BG_PADDING_V + bg_y = canvas_h - INDICATOR_H - bg_h + baseline_y = bg_y + BG_PADDING_V + base_icon_size + + rendered_widths = [base_icon_size * item.current_scale for item in items] + total_w = sum(rendered_widths) + max(n - 1, 0) * ICON_GAP + cursor_x = (canvas_w - total_w) / 2.0 + + for i, item in enumerate(items): + iw = rendered_widths[i] + ih = base_icon_size * item.current_scale + ix = cursor_x + iy = baseline_y - ih + item.render_x = ix + item.render_y = iy + item.render_w = iw + item.render_h = ih + item.drag_index = i + cursor_x += iw + ICON_GAP + + @staticmethod + def background_rect( + items: List[DockItem], + base_icon_size: int, + canvas_w: int, + canvas_h: int, + ) -> Tuple[float, float, float, float]: + bg_h = base_icon_size + 2 * BG_PADDING_V + bg_y = canvas_h - INDICATOR_H - bg_h + + if items: + min_x = min(item.render_x for item in items) + max_x = max(item.render_x + item.render_w for item in items) + bg_x = min_x - BG_PADDING_H + bg_w = (max_x - min_x) + 2 * BG_PADDING_H + else: + bg_x = BG_PADDING_H + bg_w = canvas_w - 2 * BG_PADDING_H + + return bg_x, bg_y, bg_w, bg_h diff --git a/src/window/dock/main.py b/src/window/dock/main.py new file mode 100644 index 0000000..f15c205 --- /dev/null +++ b/src/window/dock/main.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from fabric.utils import GLib, logger, random +from fabric.widgets.box import Box +from fabric.widgets.eventbox import EventBox +from fabric.widgets.revealer import Revealer +from fabric.widgets.wayland import WaylandWindow as Window + +from services.config import config, on_config_change +from utils.occlusion import check_occlusion + +from .canvas import DockCanvas + + +class Dock(Window): + def __init__(self): + super().__init__( + layer="top", + anchor="bottom center", + exclusivity="none", + title="modus-dock", + name="dock", + ) + + screen = self.get_screen() + if screen: + visual = screen.get_rgba_visual() + if visual: + self.set_visual(visual) + self.set_app_paintable(True) + + self.canvas = DockCanvas(self) + + canvas_box = Box( + name="dock-canvas-box", + children=[self.canvas], + ) + + self.revealer = Revealer( + child=canvas_box, + transition_duration=200, + transition_type="slide-up", + ) + + hover_strip = Box( + name="dock-hover-strip", + ) + + outer_box = Box( + name="dock-outer-box", + orientation="v", + children=[self.revealer, hover_strip], + ) + + self.children = EventBox( + events=["enter-notify", "leave-notify"], + child=outer_box, + on_enter_notify_event=lambda *_: self.on_hover_enter(), + on_leave_notify_event=lambda *_: self.on_hover_leave(), + ) + + self.dock_height = 100 + self.is_hovered = False + self.hide_ticket = 0 + self._startup_grace = True + + self.revealer.set_reveal_child(True) + self.show_all() + + on_config_change(self._on_config_change) + + if config().get("dock_auto_hide", True): + GLib.timeout_add(2000, self._start_occlusion_monitoring) + else: + self._occlusion_timer_id = None + + def _start_occlusion_monitoring(self) -> bool: + self._startup_grace = False + self.setup_occlusion_monitoring() + return False + + def _on_config_change(self, new_config, old_config) -> None: + if config().has_changed("dock_enabled", old_config): + self._update_visibility() + + if config().has_changed("dock_icon_size", old_config): + self.canvas.update_icon_size() + + if config().has_changed("dock_auto_hide", old_config): + if new_config.get("dock_auto_hide", True): + self.setup_occlusion_monitoring() + else: + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + self.revealer.set_reveal_child(True) + + if config().has_changed("dock_hide_special_workspace_apps", old_config): + self.canvas._rebuild_model() + + def _update_visibility(self) -> None: + if config().get("dock_enabled", True): + self.show() + self.revealer.set_reveal_child(True) + else: + self.hide() + + def on_hover_enter(self) -> None: + self.is_hovered = True + self.hide_ticket = random.getrandbits(32) + self.revealer.set_reveal_child(True) + + def on_hover_leave(self) -> None: + self.is_hovered = False + ticket = random.getrandbits(32) + self.hide_ticket = ticket + + def delayed_hide(t): + if t == self.hide_ticket and not self.is_hovered: + if config().get("dock_auto_hide", True): + is_occ = config().get( + "dock_always_occluded", False + ) or check_occlusion(("bottom", self.dock_height)) + if is_occ: + self.revealer.set_reveal_child(False) + return False + + GLib.timeout_add(500, delayed_hide, ticket) + + def setup_occlusion_monitoring(self) -> None: + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + + def check_dock_occlusion() -> bool: + try: + if config().get("dock_always_occluded", False): + is_occ = True + else: + is_occ = check_occlusion(("bottom", self.dock_height)) + + if is_occ and not self.is_hovered and self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(False) + elif not is_occ and not self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(True) + elif is_occ and self.is_hovered: + if not self.revealer.get_reveal_child(): + self.revealer.set_reveal_child(True) + except Exception as e: + logger.error(f"[Dock] Occlusion check error: {e}") + return True + + self._occlusion_timer_id = GLib.timeout_add(300, check_dock_occlusion) + + def destroy(self) -> None: + if hasattr(self, "_occlusion_timer_id") and self._occlusion_timer_id: + GLib.source_remove(self._occlusion_timer_id) + self._occlusion_timer_id = None + + if hasattr(self, "canvas") and self.canvas: + self.canvas.destroy() + + super().destroy() diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index 42a18cc..8cabcd8 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -625,38 +625,63 @@ class NotificationWidget(Box): return get_fallback_notification_icon((width, height)) def _get_notification_pixbuf(self, notification): - """Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat""" - notification_id = getattr(notification, "id", int(time.time())) + """Notification pixbuf — loads notification image or app icon, scaled consistently.""" + pixbuf = None - try: - # Try to get cached notification image first - if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: + # 1. Try cache_metadata keys first (cheap — no disk/pixbuf load) + cache_meta = getattr(notification, "cache_metadata", {}) or {} + notif_key = cache_meta.get("notification_image_cache_key") + if notif_key: + try: + pixbuf = get_cached_notification_image(notif_key) + except Exception: + pass + + # 2. Try notification image via image_pixbuf (only if no cached) + if not pixbuf: + try: + img = getattr(notification, "image_pixbuf", None) + if img is not None: + nid = getattr(notification, "id", 0) + ck = get_notification_image_cache_key(nid, img) + cached = get_cached_notification_image(ck) + if cached: + pixbuf = cached + except Exception: + pass + + # 3. App icon via cache_metadata + if not pixbuf: + app_key = cache_meta.get("app_icon_cache_key") + if app_key: try: - cache_key = get_notification_image_cache_key( - notification_id, notification.image_pixbuf - ) - cached_image = get_cached_notification_image(cache_key) - if cached_image: - return cached_image - except Exception as image_error: - logger.debug(f"Notification image processing failed: {image_error}") - # Continue to app icon fallback + from window.notification.unified_cache import get_from_cache - except Exception as e: - logger.debug(f"Failed to process notification image: {e}") + pixbuf = get_from_cache(app_key, (35, 35)) + except Exception: + pass + + # 4. Direct app icon caching + if not pixbuf: + app_icon = getattr(notification, "app_icon", None) + if app_icon: + try: + pixbuf = cache_notification_icon(app_icon, (35, 35)) + except Exception: + pass + + # 5. Fallback + if not pixbuf: + pixbuf = get_fallback_notification_icon((35, 35)) - # Use cached app icon as fallback try: - app_icon_source = getattr(notification, "app_icon", None) - if app_icon_source: - cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) - if cached_app_icon: - return cached_app_icon - except Exception as e: - logger.debug(f"Failed to get cached app icon: {e}") - - # Ultimate fallback - return get_fallback_notification_icon((35, 35)) + return pixbuf.scale_simple( + NOTIFICATION_IMAGE_SIZE, + NOTIFICATION_IMAGE_SIZE, + GdkPixbuf.InterpType.BILINEAR, + ) + except Exception: + return pixbuf def create_action_buttons(self, notification): return Box( @@ -1122,10 +1147,11 @@ class ModusNoti(Window): self.current_notification = new_box - # Clear any existing children + # Clear and destroy any existing children for child in list(self.notifications.children): try: self.notifications.remove(child) + child.destroy() except Exception as e: logger.error(f"An error occurred: {e}") @@ -1154,10 +1180,11 @@ class ModusNoti(Window): GLib.source_remove(self._transition_timer_id) self._transition_timer_id = None - # Safely remove notification box + # Safely remove and destroy notification box try: if notification_box in self.notifications.children: self.notifications.remove(notification_box) + notification_box.destroy() except Exception as e: logger.error(f"An error occurred: {e}") @@ -1197,8 +1224,11 @@ class ModusNoti(Window): # Also clean current notification if showing if self.current_notification: - # Mark for cache cleanup when clearing queue self.current_notification.notif_box._should_cleanup_cache = True + try: + self.current_notification.notification.close("dismissed-by-user") + except Exception as e: + logger.error(f"An error occurred: {e}") # Clear animation timers if self._transition_timer_id: diff --git a/src/window/notification/notification_center.py b/src/window/notification/notification_center.py index b4c1a64..177300f 100644 --- a/src/window/notification/notification_center.py +++ b/src/window/notification/notification_center.py @@ -698,6 +698,8 @@ class NotificationCenter(AppletWindow): self.add_keybinding("Escape", self._on_escape_pressed) + MAX_GROUPS = 15 + def _rebuild_notification_groups(self): """Rebuild notification groups from scratch with enhanced asset preloading and debugging""" # Clear existing groups @@ -709,23 +711,27 @@ class NotificationCenter(AppletWindow): child.destroy() # Group notifications by app name and preload assets with debugging - rebuild_count = 0 for cached_notification in notification_service.cached_notifications: app_name = cached_notification._notification.app_name - getattr(cached_notification._notification, "id", None) # Skip ignored apps during rebuild if app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: continue - # Preload assets for each cached notification to ensure display consistency - preload_notification_assets(cached_notification._notification) - self.notification_groups[app_name].append(cached_notification) - rebuild_count += 1 + # Sort groups by most recent notification + sorted_groups = sorted( + self.notification_groups.items(), + key=lambda item: max( + (getattr(n._notification, "id", 0) for n in item[1]), default=0 + ), + reverse=True, + )[: self.MAX_GROUPS] + + self.notification_groups.clear() # Create group widgets and handle limited apps - for app_name, notifications in self.notification_groups.items(): + for app_name, notifications in sorted_groups: # Sort notifications by ID (highest ID first - newest notifications) # This ensures the latest notifications appear at the top of each group notifications.sort( @@ -736,11 +742,10 @@ class NotificationCenter(AppletWindow): if app_name in data.NOTIFICATION_LIMITED_APPS_HISTORY: if len(notifications) > 5: # Keep only the 5 most recent notifications - self.notification_groups[app_name] = notifications[:5] + notifications = notifications[:5] - group_widget = ExpandableNotificationGroup( - app_name, self.notification_groups[app_name] - ) + group_widget = ExpandableNotificationGroup(app_name, notifications) + self.notification_groups[app_name] = notifications self.group_widgets[app_name] = group_widget self.notifications_box.add(group_widget) @@ -809,9 +814,10 @@ class NotificationCenter(AppletWindow): def _refresh_group_widget(self, group_widget): """Refresh a group widget's content""" try: - # Remove existing children + # Remove and destroy existing children for child in group_widget.get_children(): group_widget.remove(child) + child.destroy() # Recreate content group_widget.create_collapsed_state() @@ -886,6 +892,8 @@ class NotificationCenter(AppletWindow): # Clear all remaining cached notification images AND icons when clear all is clicked cleanup_all_notification_caches() # Clear ALL caches (icons + images) + for child in self.notifications_box.get_children(): + child.destroy() notification_service.clear_all_cached_notifications() self.hide() diff --git a/src/window/notification/unified_cache.py b/src/window/notification/unified_cache.py index d690d89..96515af 100644 --- a/src/window/notification/unified_cache.py +++ b/src/window/notification/unified_cache.py @@ -1,6 +1,7 @@ import hashlib import time import uuid +from collections import OrderedDict from fabric.utils import GdkPixbuf, get_relative_path, logger, os @@ -9,6 +10,9 @@ import shared.data as data # Unified notification cache directory (for both app icons and notification images) UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") +_MEMORY_PIXBUF_CACHE: OrderedDict[str, GdkPixbuf.Pixbuf] = OrderedDict() +_MEMORY_CACHE_MAX = 64 + def ensure_cache_dir(): """Ensure unified notification cache directory exists""" @@ -72,16 +76,23 @@ def save_to_cache(pixbuf, cache_key, size=None): def get_from_cache(cache_key, size=None): """Get a cached asset or return None if not found""" + mem_key = f"{cache_key}:{size}" + if mem_key in _MEMORY_PIXBUF_CACHE: + _MEMORY_PIXBUF_CACHE.move_to_end(mem_key) + return _MEMORY_PIXBUF_CACHE[mem_key] try: cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, f"{cache_key}.png") if os.path.exists(cache_path): - # logger.debug(f"Using cached asset: {cache_key}") if size: - return GdkPixbuf.Pixbuf.new_from_file_at_scale( + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( cache_path, size[0], size[1], True ) else: - return GdkPixbuf.Pixbuf.new_from_file(cache_path) + pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) + while len(_MEMORY_PIXBUF_CACHE) >= _MEMORY_CACHE_MAX: + _MEMORY_PIXBUF_CACHE.popitem(last=False) + _MEMORY_PIXBUF_CACHE[mem_key] = pixbuf + return pixbuf except Exception as e: logger.warning(f"Failed to load cached asset: {e}") return None @@ -99,6 +110,9 @@ def cleanup_cache(cache_key=None): ) if os.path.exists(cache_path): os.unlink(cache_path) + mem_keys = [k for k in _MEMORY_PIXBUF_CACHE if k.startswith(cache_key)] + for k in mem_keys: + _MEMORY_PIXBUF_CACHE.pop(k, None) else: # Remove all cached assets for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): @@ -108,6 +122,7 @@ def cleanup_cache(cache_key=None): os.unlink(filepath) except Exception as e: logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _MEMORY_PIXBUF_CACHE.clear() except Exception as e: logger.warning(f"Failed to cleanup cache: {e}") diff --git a/src/window/osd/components/audio.py b/src/window/osd/components/audio.py index 1ead260..658006d 100644 --- a/src/window/osd/components/audio.py +++ b/src/window/osd/components/audio.py @@ -13,6 +13,7 @@ class AudioOSDContainer(BaseOSDContainer): self.audio = Audio() self._last_volume = None self._last_muted = None + self._speaker_handler_id = None self._setup_specific_components() self._connect_specific_signals() @@ -44,7 +45,11 @@ class AudioOSDContainer(BaseOSDContainer): def _connect_speaker_signals(self): if self.audio.speaker: - self.audio.speaker.connect("changed", self._on_speaker_stream_changed) + if self._speaker_handler_id is not None: + self.audio.speaker.disconnect(self._speaker_handler_id) + self._speaker_handler_id = self.audio.speaker.connect( + "changed", self._on_speaker_stream_changed + ) self._sync_with_audio() def _on_speaker_changed(self, *_): @@ -104,8 +109,8 @@ class AudioOSDContainer(BaseOSDContainer): """Disconnect signals from Audio service""" try: self.audio.disconnect_by_func(self._on_speaker_changed) - if self.audio.speaker: - self.audio.speaker.disconnect_by_func(self._on_speaker_stream_changed) + if self.audio.speaker and self._speaker_handler_id is not None: + self.audio.speaker.disconnect(self._speaker_handler_id) except Exception as e: logger.error(f"An error occurred: {e}") super().destroy() diff --git a/src/window/panel/components/custom_mods.py b/src/window/panel/components/custom_mods.py index 5a73ac9..a5cbc6f 100644 --- a/src/window/panel/components/custom_mods.py +++ b/src/window/panel/components/custom_mods.py @@ -2,11 +2,10 @@ from pathlib import Path import tomlkit -from fabric.utils import GLib, logger +from fabric.utils import GLib, Gio, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label -from gi.repository import Gio from shared.window.applet_window import AppletWindow from utils.functions import run_command, thread @@ -171,6 +170,7 @@ class CustomMods(Box): def _rebuild(self, mods): for child in list(self.get_children()): self.remove(child) + child.destroy() for popup in self._mod_popups.values(): if popup: diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py index 7ef8e70..722cbc2 100644 --- a/src/window/panel/components/globalmenu.py +++ b/src/window/panel/components/globalmenu.py @@ -357,9 +357,6 @@ class GlobalMenuDropdowns: def _on_menu_changed(self, _, menu_items: list): """Handle menu changes from the GlobalMenuService.""" - logger.info( - f"[GlobalMenu] _on_menu_changed called with {len(menu_items)} items" - ) self._rebuild_dynamic_menus(menu_items) def _rebuild_dynamic_menus(self, menu_items: list): diff --git a/src/window/panel/main.py b/src/window/panel/main.py index 6d2c1c0..6181dfb 100644 --- a/src/window/panel/main.py +++ b/src/window/panel/main.py @@ -13,17 +13,16 @@ from utils.roam import modus_service from utils.utils import setup_cursor_hover, svg_file from window.controlcenter.main import ModusControlCenter from window.notification.notification_center import NotificationCenter +from window.panel.components.custom_mods import CustomMods from window.panel.components.enhanced_system_tray import apply_enhanced_system_tray from window.panel.components.globalmenu import GlobalMenu -from window.panel.components.custom_mods import CustomMods -from window.panel.notch import Notch from window.panel.components.indicators import ( BatteryIndicator, BluetoothIndicator, NetworkIndicator, ) - from window.panel.components.workspace import WorkspaceIndicator +from window.panel.notch import Notch apply_enhanced_system_tray() diff --git a/src/window/panel/notch/player.py b/src/window/panel/notch/player.py index d7dce75..62e39ce 100644 --- a/src/window/panel/notch/player.py +++ b/src/window/panel/notch/player.py @@ -2,10 +2,9 @@ import math import os import time -from fabric.utils import GLib, Gtk +from fabric.utils import GLib, Gtk, GdkPixbuf from fabric.widgets.box import Box from fabric.widgets.centerbox import CenterBox -from gi.repository import GdkPixbuf from services.mpris import PlayerService from shared.widgets.custom_image import CustomImage diff --git a/src/window/switcher.py b/src/window/switcher.py index 49545aa..43d2ea1 100644 --- a/src/window/switcher.py +++ b/src/window/switcher.py @@ -1,7 +1,6 @@ -import json +from typing import Dict, Tuple -from fabric.hyprland.widgets import get_hyprland_connection -from fabric.utils import Gdk, GLib, logger +from fabric.utils import Gdk, GdkPixbuf, logger from fabric.widgets.box import Box from fabric.widgets.eventbox import EventBox from fabric.widgets.image import Image @@ -9,6 +8,7 @@ from fabric.widgets.label import Label from fabric.widgets.wayland import WaylandWindow as Window from services.config import config, on_config_change +from services.modus import get_clients, get_active_window, focus_window from utils.functions import is_special_workspace from utils.icon_resolver import IconResolver @@ -22,18 +22,17 @@ class ApplicationSwitcher(Window): anchor="center", exclusivity="auto", keyboard_mode="exclusive", - visible=False, # Start hidden until explicitly shown + visible=False, **kwargs, ) - self.conn = get_hyprland_connection() self.icon_resolver = IconResolver() self.windows = [] self.current_index = 0 self.tab_pressed = False self.icon_size = 96 + self._pixbuf_cache: Dict[Tuple[str, int], GdkPixbuf.Pixbuf] = {} - # Subscribe to config changes on_config_change(self._on_config_changed) container = Box( @@ -83,7 +82,6 @@ class ApplicationSwitcher(Window): return config().get("window_switcher_items_per_row", 10) def _on_config_changed(self, new_config, old_config): - # Refresh windows if settings that affect display change if config().has_changed( "hide_special_workspace", old_config ) or config().has_changed("window_switcher_items_per_row", old_config): @@ -104,18 +102,20 @@ class ApplicationSwitcher(Window): self.ungrab_keyboard() def create_icon_for_window(self, window): - """Create an icon image for a specific window""" class_name = window.get("class", "").lower() - icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size) - if not icon_img: - icon_img = self.icon_resolver.get_icon_pixbuf( - "application-x-executable-symbolic", self.icon_size - ) - + key = (class_name, self.icon_size) + if key not in self._pixbuf_cache: + icon_img = self.icon_resolver.get_icon_pixbuf(class_name, self.icon_size) + if not icon_img: + icon_img = self.icon_resolver.get_icon_pixbuf( + "application-x-executable-symbolic", self.icon_size + ) + if icon_img: + self._pixbuf_cache[key] = icon_img + icon_img = self._pixbuf_cache.get(key) icon_image = Image() if icon_img: icon_image.set_from_pixbuf(icon_img) - return icon_image def _is_special_workspace(self, client): @@ -126,29 +126,23 @@ class ApplicationSwitcher(Window): child.destroy() try: - clients_data = self.conn.send_command("j/clients").reply - if not clients_data: + clients = get_clients() + if not clients: return - clients = json.loads(clients_data.decode("utf-8")) - # Filter out hidden windows and optionally special workspace windows - filtered_windows = [] hide_special = config().get("hide_special_workspace", True) + filtered_windows = [] for c in clients: if c.get("hidden", False): continue - # Skip clients in special workspaces if the setting is enabled if hide_special and self._is_special_workspace(c): continue filtered_windows.append(c) self.windows = filtered_windows - active_data = self.conn.send_command("j/activewindow").reply - active_window = ( - json.loads(active_data.decode("utf-8")) if active_data else None - ) + active_window = get_active_window() self.current_index = 0 if active_window: @@ -157,7 +151,6 @@ class ApplicationSwitcher(Window): self.current_index = i break - # Create vertical container for rows rows_box = Box(orientation="v", spacing=16) self.view.add(rows_box) @@ -175,7 +168,6 @@ class ApplicationSwitcher(Window): ) rows_box.add(current_row) - # Create icon image for this window icon_image = self.create_icon_for_window(window) button_content = Box( @@ -282,7 +274,6 @@ class ApplicationSwitcher(Window): return False def update_selection(self): - # Flatten all buttons from all rows to find them by index all_buttons = [] rows_box = self.view.get_children()[0] if self.view.get_children() else None if rows_box: @@ -292,15 +283,12 @@ class ApplicationSwitcher(Window): for i, child in enumerate(all_buttons): if i == self.current_index: child.add_style_class("active") - # Update selection label current_window = self.windows[self.current_index] app_class = current_window.get("class", "Unknown") - # capitalize if it's all lowercase if app_class.islower(): app_class = app_class.capitalize() self.selection_label.set_label(app_class) - # Update workspace label workspace = current_window.get("workspace", {}) workspace_name = workspace.get("name", "Unknown") self.workspace_label.set_label(f"Workspace {workspace_name}") @@ -314,12 +302,7 @@ class ApplicationSwitcher(Window): window = self.windows[self.current_index] address = window.get("address") if address: - try: - GLib.spawn_command_line_async( - f"hyprctl dispatch 'hl.dsp.focus({{ window = \"address:{address}\" }})'" - ) - except Exception as e: - logger.error(f"Failed to focus window: {e}") + focus_window(address) def _on_item_clicked(self, index): self.current_index = index