diff --git a/src/shared/window/dropdown.py b/src/shared/window/dropdown.py index 5a9a595..2b83887 100644 --- a/src/shared/window/dropdown.py +++ b/src/shared/window/dropdown.py @@ -88,3 +88,16 @@ class ModusDropdown(PopupWindow): return self.set_visible(False) modus_service.dropdowns_hide = not modus_service.dropdowns_hide + + def destroy(self): + """Clean up resources and global references""" + global dropdowns + if self in dropdowns: + dropdowns.remove(self) + + try: + modus_service.disconnect_by_func(self.hide_dropdown) + except Exception: + pass + + super().destroy() diff --git a/src/shared/window/mousecapture.py b/src/shared/window/mousecapture.py index 1de5ff9..088f495 100644 --- a/src/shared/window/mousecapture.py +++ b/src/shared/window/mousecapture.py @@ -299,3 +299,25 @@ class DropDownMouseCapture(MouseCapture): if modus_service.current_dropdown == self.child_window.id: return return self.hide_child_window(widget, event) + + def destroy(self): + """Clean up signal connections""" + try: + modus_service.disconnect_by_func(self.dropdowns_hide_changed) + except Exception: + pass + super().destroy() + + +def add_destroy_to_mousecapture(): + # Base MouseCapture + def mc_destroy(self): + # Child window should be destroyed by its owner, but we should null references + self.child_window = None + Window.destroy(self) + + MouseCapture.destroy = mc_destroy + + +# Apply destroy to base class +add_destroy_to_mousecapture() diff --git a/src/window/controlcenter/battery.py b/src/window/controlcenter/battery.py index 6d175eb..e5c28b0 100644 --- a/src/window/controlcenter/battery.py +++ b/src/window/controlcenter/battery.py @@ -510,11 +510,7 @@ class BluetoothConnections(Box): return False # Skip if update in progress or client not available/enabled - if ( - self._update_in_progress - or not self.client - or not self.client.enabled - ): + if self._update_in_progress or not self.client or not self.client.enabled: return True # Continue monitoring try: diff --git a/src/window/controlcenter/bluetooth.py b/src/window/controlcenter/bluetooth.py index 972fd51..031eed4 100644 --- a/src/window/controlcenter/bluetooth.py +++ b/src/window/controlcenter/bluetooth.py @@ -510,11 +510,7 @@ class BluetoothConnections(Box): return False # Skip if update in progress or client not available/enabled - if ( - self._update_in_progress - or not self.client - or not self.client.enabled - ): + if self._update_in_progress or not self.client or not self.client.enabled: return True # Continue monitoring try: diff --git a/src/window/controlcenter/expanded_player.py b/src/window/controlcenter/expanded_player.py index 486550f..1a3463e 100644 --- a/src/window/controlcenter/expanded_player.py +++ b/src/window/controlcenter/expanded_player.py @@ -184,6 +184,20 @@ class EmbeddedExpandedPlayer(Box): # This will automatically update as MPRIS players change pass + def suspend(self): + """Suspend updates when control center is hidden""" + if hasattr(self, "player_stack") and self.player_stack: + for child in self.player_stack.get_children(): + if hasattr(child, "suspend"): + child.suspend() + + def resume(self): + """Resume updates when control center is shown""" + if hasattr(self, "player_stack") and self.player_stack: + for child in self.player_stack.get_children(): + if hasattr(child, "resume"): + child.resume() + def destroy(self): """Clean up resources and prevent memory leaks""" logger.debug("🗑️ EmbeddedExpandedPlayer cleanup starting") @@ -787,7 +801,9 @@ class PlayerBox(Box): "label", GObject.BindingFlags.DEFAULT, lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" + re.sub(r"\r?\n", " ", x) + if x != "" and x is not None + else "No Title" ), # type: ignore ) ) @@ -811,7 +827,9 @@ class PlayerBox(Box): "label", GObject.BindingFlags.DEFAULT, lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Album" + re.sub(r"\r?\n", " ", x) + if x != "" and x is not None + else "No Album" ), # type: ignore ) ) @@ -1097,6 +1115,27 @@ class PlayerBox(Box): # Start new timer and store its ID self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + def suspend(self): + """Stop timer and other active updates when hidden""" + if self._seekbar_timer_id: + try: + GLib.source_remove(self._seekbar_timer_id) + except Exception: + pass + self._seekbar_timer_id = None + logger.debug( + f"[PlayerBox] Suspended timer for {self.player.player_name if self.player else 'unknown'}" + ) + + def resume(self): + """Restart timer and updates when shown""" + if self.exit or self.player is None: + return + + if not self._seekbar_timer_id: + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + logger.debug(f"[PlayerBox] Resumed timer for {self.player.player_name}") + def _cleanup_temp_files(self): """Clean up temporary artwork files.""" for temp_file in self.temp_artwork_files: diff --git a/src/window/controlcenter/main.py b/src/window/controlcenter/main.py index 477eb90..4012b5a 100644 --- a/src/window/controlcenter/main.py +++ b/src/window/controlcenter/main.py @@ -8,6 +8,10 @@ from fabric.widgets.label import Label from fabric.widgets.scale import Scale from fabric.widgets.wayland import WaylandWindow as Window +from services.brightness import Brightness +from services.network import NetworkClient +from utils.roam import audio_service, modus_service +from utils.utils import svg_file from window.controlcenter.bluetooth import ( BluetoothConnections, set_bluetooth_enabled_with_fallback, @@ -17,10 +21,6 @@ from window.controlcenter.nightlight import create_night_light_widget from window.controlcenter.per_app_volume import PerAppVolumeControl from window.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager from window.controlcenter.wifi import WifiConnections -from services.brightness import Brightness -from services.network import NetworkClient -from utils.roam import audio_service, modus_service -from utils.utils import svg_file brightness_service = Brightness.get_initial() @@ -487,11 +487,27 @@ class ModusControlCenter(Window): def _on_visibility_changed(self, widget, param): """Handle visibility changes for resource management""" if not self.get_visible(): + # Suspend player updates + if hasattr(self, "music_widget") and hasattr(self.music_widget, "suspend"): + self.music_widget.suspend() + if hasattr(self, "_expanded_player_widget") and hasattr( + self._expanded_player_widget, "suspend" + ): + self._expanded_player_widget.suspend() + # Just disconnect signals and reset state flags - don't destroy widgets self._disconnect_signals_when_hidden() else: self._initialize_resources() + # Resume player updates + if hasattr(self, "music_widget") and hasattr(self.music_widget, "resume"): + self.music_widget.resume() + if hasattr(self, "_expanded_player_widget") and hasattr( + self._expanded_player_widget, "resume" + ): + self._expanded_player_widget.resume() + def _initialize_resources(self): """Initialize resources and connect signals when the control center becomes visible.""" if self._resources_initialized: @@ -512,11 +528,28 @@ class ModusControlCenter(Window): # Store signal connections as (obj, handler_id) tuples for proper cleanup self._signal_connections.extend( [ - (audio_service, audio_service.connect("changed", self.audio_changed)), - (audio_service, audio_service.connect("changed", self.volume_changed)), - (modus_service, modus_service.connect("wlan-changed", self.wlan_changed)), - (modus_service, modus_service.connect("bluetooth-changed", self.bluetooth_changed)), - (modus_service, modus_service.connect("dont-disturb-changed", self.dnd_changed)), + ( + audio_service, + audio_service.connect("changed", self.audio_changed), + ), + ( + audio_service, + audio_service.connect("changed", self.volume_changed), + ), + ( + modus_service, + modus_service.connect("wlan-changed", self.wlan_changed), + ), + ( + modus_service, + modus_service.connect( + "bluetooth-changed", self.bluetooth_changed + ), + ), + ( + modus_service, + modus_service.connect("dont-disturb-changed", self.dnd_changed), + ), ] ) @@ -525,7 +558,10 @@ class ModusControlCenter(Window): self.brightness_scale.connect("change-value", 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)) + ( + brightness_service, + brightness_service.connect("screen", self.brightness_changed), + ) ) # Connect volume scale signals @@ -547,7 +583,28 @@ class ModusControlCenter(Window): def _disconnect_signals_when_hidden(self): """Disconnect signals when hidden to reduce resource usage, but keep widgets intact""" try: - # Just disconnect signals and reset flags - don't destroy widgets + # Actually disconnect the signals we tracked + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + self._signal_connections.clear() + + # Disconnect direct scale signals (connected in _initialize_resources) + if hasattr(self, "volume_scale"): + try: + self.volume_scale.disconnect_by_func(self.set_volume) + self.volume_scale.disconnect_by_func(self.on_volume_scroll) + except Exception: + pass + if hasattr(self, "brightness_scale"): + try: + self.brightness_scale.disconnect_by_func(self.set_brightness) + self.brightness_scale.disconnect_by_func(self.on_brightness_scroll) + except Exception: + pass + self._signals_connected = False self._resources_initialized = False @@ -560,6 +617,8 @@ class ModusControlCenter(Window): # Reset current view self.current_view = "main" + logger.debug("Control center signals disconnected while hidden") + except Exception as e: logger.warning(f"Control center signal disconnection failed: {e}") diff --git a/src/window/controlcenter/per_app_volume.py b/src/window/controlcenter/per_app_volume.py index 3445b8a..af63e82 100644 --- a/src/window/controlcenter/per_app_volume.py +++ b/src/window/controlcenter/per_app_volume.py @@ -170,7 +170,12 @@ class PerAppVolumeControl(Box): def _populate_apps(self): """Populate the widget with current audio applications""" - # Clear existing widgets + # Clear and DESTROY existing widgets to prevent memory leaks + for child in list(self.apps_container.get_children()): + try: + child.destroy() + except Exception: + pass self.apps_container.children = [] self._app_widgets.clear() diff --git a/src/window/controlcenter/player.py b/src/window/controlcenter/player.py index 1023fd5..14cfa2d 100644 --- a/src/window/controlcenter/player.py +++ b/src/window/controlcenter/player.py @@ -259,6 +259,20 @@ class PlayerBoxStack(Box): super().destroy() + def suspend(self): + """Suspend all child players when hidden""" + for child in self.player_stack.get_children(): + if hasattr(child, "suspend"): + child.suspend() + logger.debug("[PlayerBoxStack] Suspended child players") + + def resume(self): + """Resume all child players when shown""" + for child in self.player_stack.get_children(): + if hasattr(child, "resume"): + child.resume() + logger.debug("[PlayerBoxStack] Resumed child players") + def _periodic_cleanup(self): """Enhanced cleanup for reuse - clean internal state and free memory""" try: @@ -776,7 +790,9 @@ class PlayerBox(Box): "label", GObject.BindingFlags.DEFAULT, lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Title" + re.sub(r"\r?\n", " ", x) + if x != "" and x is not None + else "No Title" ), # type: ignore ) ) @@ -904,6 +920,37 @@ class PlayerBox(Box): for handler_id in connections: self._signal_connections.append((self.player, handler_id)) + def suspend(self): + """Disconnect signals when hidden""" + self.exit = True + for obj, handler_id in self._signal_connections: + try: + obj.disconnect(handler_id) + except Exception: + pass + self._signal_connections.clear() + logger.debug( + f"[PlayerBox] Suspended signals for {self.player.player_name if self.player else 'unknown'}" + ) + + def resume(self): + """Reconnect signals when shown""" + if self.player is None: + return + self.exit = False + if not self._signal_connections: + connections = bulk_connect( + self.player, + { + "closed": self._on_player_exit, + "notify::playback-status": self._on_playback_change, + "notify::metadata": self._on_metadata, + }, + ) + for handler_id in connections: + self._signal_connections.append((self.player, handler_id)) + logger.debug(f"[PlayerBox] Resumed signals for {self.player.player_name}") + def destroy(self): """Clean up all resources when the widget is destroyed.""" # Set exit flag first — stops all callbacks from doing work diff --git a/src/window/controlcenter/wifi.py b/src/window/controlcenter/wifi.py index 10870bb..929a8f3 100644 --- a/src/window/controlcenter/wifi.py +++ b/src/window/controlcenter/wifi.py @@ -266,7 +266,12 @@ class WifiConnections(Box): # Wait for network service to be ready — track signal ID self._signal_ids.append( - (self.network_service, self.network_service.connect("wifi-device-added", self.on_network_ready)) + ( + self.network_service, + self.network_service.connect( + "wifi-device-added", self.on_network_ready + ), + ) ) # Create pull-to-refresh indicator @@ -392,18 +397,30 @@ class WifiConnections(Box): # Connect to WiFi service signals — track IDs for cleanup self._signal_ids.append( - (self.wifi_service, self.wifi_service.connect( - "notify::wireless-enabled", self.on_wifi_enabled_changed - )) + ( + self.wifi_service, + self.wifi_service.connect( + "notify::wireless-enabled", self.on_wifi_enabled_changed + ), + ) ) self._signal_ids.append( - (self.wifi_service, self.wifi_service.connect("changed", self.update_networks)) + ( + self.wifi_service, + self.wifi_service.connect("changed", self.update_networks), + ) ) self._signal_ids.append( - (self.wifi_service, self.wifi_service.connect("ap-added", self.update_networks)) + ( + self.wifi_service, + self.wifi_service.connect("ap-added", self.update_networks), + ) ) self._signal_ids.append( - (self.wifi_service, self.wifi_service.connect("ap-removed", self.update_networks)) + ( + self.wifi_service, + self.wifi_service.connect("ap-removed", self.update_networks), + ) ) # Initial network update diff --git a/src/window/desktop/widget.py b/src/window/desktop/widget.py index 2b665dd..1b75acb 100644 --- a/src/window/desktop/widget.py +++ b/src/window/desktop/widget.py @@ -43,7 +43,12 @@ def http_get_json( ) -> Optional[Dict]: """Helper to perform GET requests and return JSON using built-in urllib.""" try: - req = urllib.request.Request(url, headers=headers or {}) + # Some APIs require a User-Agent or they will return 403 Forbidden + default_headers = {"User-Agent": "Modus-Desktop/1.0"} + if headers: + default_headers.update(headers) + + req = urllib.request.Request(url, headers=default_headers) with urllib.request.urlopen(req, timeout=timeout) as response: if response.status == 200: return json.loads(response.read().decode("utf-8")) @@ -53,13 +58,23 @@ def http_get_json( def get_location() -> str: - """Get current location using multiple IP geolocation APIs with fallback.""" + """Get current location from config or multiple IP geolocation APIs with fallback.""" + # Try to get location from config first + try: + config = load_config() + manual_location = config.get("weather_location") + if manual_location: + return manual_location + except Exception: + pass + + # Fallback to IP geolocation APIs for api_url in LOCATION_APIS: data = http_get_json(api_url, timeout=2) if data: city = data.get("city", "") if city: - return city.replace(" ", "") + return city print("All location APIs failed") return "" @@ -197,8 +212,14 @@ def update_weather(widget): debounced_perform_fetch() return True - GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update) - fetch_and_update() + def initial_fetch(): + debounced_perform_fetch() + return False + + # Trigger first fetch immediately - MUST return False to not loop in idle + GLib.idle_add(initial_fetch) + + return GLib.timeout_add_seconds(WEATHER_UPDATE_INTERVAL, fetch_and_update) def update_widget(widget, weather_info): @@ -221,9 +242,10 @@ class Weather(Box): ) self.parent = parent self.weatherinfo = None + self._weather_timer_id = None self._create_labels() self._layout_labels() - update_weather(self) + self._weather_timer_id = update_weather(self) def _create_labels(self): self.header = Box(orientation="h", h_expand=True) @@ -291,6 +313,13 @@ class Weather(Box): self.parent.set_visible(True) + def destroy(self): + """Cleanup weather update timer""" + if self._weather_timer_id: + GLib.source_remove(self._weather_timer_id) + self._weather_timer_id = None + super().destroy() + class WeatherContainer(Box): def __init__(self, **kwargs): @@ -363,7 +392,16 @@ class Calendar(Box): self.add(self.month_label) self.add(self.days_header) self.add(self.calendar_grid) - invoke_repeater(CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed) + self._calendar_timer_id = invoke_repeater( + CALENDAR_UPDATE_INTERVAL, self.update_calendar_if_needed + ) + + def destroy(self): + """Cleanup calendar update timer""" + if hasattr(self, "_calendar_timer_id") and self._calendar_timer_id: + GLib.source_remove(self._calendar_timer_id) + self._calendar_timer_id = None + super().destroy() def _update_current_date(self): now = datetime.datetime.now() @@ -520,7 +558,14 @@ class SystemInfoBase(Box): ) def start_updates(self): - invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) + self._system_timer_id = invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) + + def destroy(self): + """Cleanup system info update timer""" + if hasattr(self, "_system_timer_id") and self._system_timer_id: + GLib.source_remove(self._system_timer_id) + self._system_timer_id = None + super().destroy() def create_info_line( self, indicator_name: str, info_text: str, value_text: str diff --git a/src/window/dock.py b/src/window/dock.py index 1c63f42..4b10c4e 100644 --- a/src/window/dock.py +++ b/src/window/dock.py @@ -84,9 +84,21 @@ class AppBar(Box): logger.error(f"[AppBar] Error updating apps: {e}") return True - GLib.timeout_add(250, update_running_apps) + self._app_monitor_timer_id = GLib.timeout_add(250, update_running_apps) GLib.idle_add(self.update_dock_apps) + def destroy(self): + """Clean up timers and children""" + if hasattr(self, "_app_monitor_timer_id") and self._app_monitor_timer_id: + GLib.source_remove(self._app_monitor_timer_id) + self._app_monitor_timer_id = None + + # Destroy context menu + if self.menu: + self.menu.destroy() + + super().destroy() + def _populate_pinned_apps(self): clear_children(self.pinned_apps_container) @@ -851,4 +863,15 @@ class Dock(Window): return True - GLib.timeout_add(300, check_dock_occlusion) + self._occlusion_timer_id = GLib.timeout_add(300, check_dock_occlusion) + + def destroy(self): + """Clean up timers and children""" + 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, "app_bar") and self.app_bar: + self.app_bar.destroy() + + super().destroy() diff --git a/src/window/launcher/main.py b/src/window/launcher/main.py index 918d0ea..9187f7c 100644 --- a/src/window/launcher/main.py +++ b/src/window/launcher/main.py @@ -720,7 +720,10 @@ class Launcher(Window): # Clear existing results for child in self.results_box.get_children(): - self.results_box.remove(child) + if isinstance(child, ResultItem): + child.destroy() + else: + self.results_box.remove(child) # Add new results for i, result in enumerate(self.results): @@ -790,7 +793,10 @@ class Launcher(Window): self.results = [] self.selected_index = 0 for child in self.results_box.get_children(): - self.results_box.remove(child) + if isinstance(child, ResultItem): + child.destroy() + else: + self.results_box.remove(child) # Keep the results scroll visible even when empty def _handle_escape_key(self) -> bool: diff --git a/src/window/launcher/plugins/otp.py b/src/window/launcher/plugins/otp.py index bf859a4..6d9669e 100644 --- a/src/window/launcher/plugins/otp.py +++ b/src/window/launcher/plugins/otp.py @@ -95,29 +95,36 @@ class OTPPlugin(PluginBase): def _selective_force_refresh(self): """Update time display in existing OTP result items.""" try: - import gc + from fabric import Application def do_update(): try: - for obj in gc.get_objects(): - if ( - hasattr(obj, "__class__") - and obj.__class__.__name__ == "Launcher" - and hasattr(obj, "results_box") - and hasattr(obj, "visible") - and obj.visible - and hasattr(obj, "results") - and obj.results - ): - has_otp_results = any( - result.data and result.data.get("type") == "totp" - for result in obj.results - if hasattr(result, "data") and result.data - ) + app = Application.get_default() + if not app: + return False - if has_otp_results: - self._update_existing_result_labels(obj.results_box) - return False + launcher = None + for window in app.get_windows(): + if window.get_name() == "launcher-window": + launcher = window + break + + if ( + launcher + and hasattr(launcher, "results_box") + and hasattr(launcher, "visible") + and launcher.visible + and hasattr(launcher, "results") + and launcher.results + ): + has_otp_results = any( + result.data and result.data.get("type") == "totp" + for result in launcher.results + if hasattr(result, "data") and result.data + ) + + if has_otp_results: + self._update_existing_result_labels(launcher.results_box) except Exception: pass return False diff --git a/src/window/notification/notification.py b/src/window/notification/notification.py index d80bfaf..e5d6cab 100644 --- a/src/window/notification/notification.py +++ b/src/window/notification/notification.py @@ -771,6 +771,7 @@ class NotificationRevealer(SlideRevealer): # Animation state self._animation_in_progress = False self._spring_timer_id = None + self._anim_timeout_id = None self._css_provider = None # Wrap notification in EventBox for swipe detection @@ -791,8 +792,8 @@ class NotificationRevealer(SlideRevealer): smooth_revealer_animation(self) - # Connect our own handler that manages the slide animation - self.notification.connect("closed", self.on_resolved) + # Connect our own handler that manages the slide animation - track ID for cleanup + self._closed_handler_id = self.notification.connect("closed", self.on_resolved) self._animation_in_progress = True @@ -924,12 +925,37 @@ class NotificationRevealer(SlideRevealer): self.hide() # Consistent timing for smooth transitions timeout_duration = self.duration + 50 - GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True)) + self._anim_timeout_id = GLib.timeout_add( + timeout_duration, lambda: self._on_animation_complete(True) + ) def destroy(self): # Clean up CSS provider and timers if self._spring_timer_id: GLib.source_remove(self._spring_timer_id) + self._spring_timer_id = None + if self._anim_timeout_id: + GLib.source_remove(self._anim_timeout_id) + self._anim_timeout_id = None + + # Disconnect notification signal + if hasattr(self, "_closed_handler_id") and self._closed_handler_id: + try: + self.notification.disconnect(self._closed_handler_id) + except Exception: + pass + self._closed_handler_id = 0 + + # Clean up CSS provider from style context + if self._css_provider: + try: + style_context = self.notif_box.get_style_context() + if style_context: + style_context.remove_provider(self._css_provider) + except Exception: + pass + self._css_provider = None + super().destroy() diff --git a/src/window/osd/components/audio.py b/src/window/osd/components/audio.py index c33d4c9..049ac12 100644 --- a/src/window/osd/components/audio.py +++ b/src/window/osd/components/audio.py @@ -98,3 +98,13 @@ class AudioOSDContainer(BaseOSDContainer): def update(self, *_): self._update_display() super().update() + + def destroy(self): + """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) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/base.py b/src/window/osd/components/base.py index 30e2197..20034eb 100644 --- a/src/window/osd/components/base.py +++ b/src/window/osd/components/base.py @@ -29,6 +29,10 @@ class BaseOSDContainer(Box): self.window.connect("leave-notify-event", self._on_leave_notify) return False + def destroy(self): + self.cleanup_all_handlers() + super().destroy() + def _on_enter_notify(self, widget, event): if event.detail != Gdk.NotifyType.INFERIOR: self._is_hovered = True diff --git a/src/window/osd/components/brightness.py b/src/window/osd/components/brightness.py index fd54841..8499d72 100644 --- a/src/window/osd/components/brightness.py +++ b/src/window/osd/components/brightness.py @@ -57,3 +57,13 @@ class BrightnessOSDContainer(BaseOSDContainer): def update(self, *_): self._update_display() super().update() + + def destroy(self): + """Disconnect signals from Brightness service""" + try: + self.brightness_service.disconnect_by_func( + self._on_screen_brightness_changed + ) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/capslock.py b/src/window/osd/components/capslock.py index 4ff7543..febea10 100644 --- a/src/window/osd/components/capslock.py +++ b/src/window/osd/components/capslock.py @@ -45,3 +45,11 @@ class CapsLockOSDContainer(BaseOSDContainer): def update(self, *_): # We don't need a separate display update here as it's handled in the signal super().update() + + def destroy(self): + """Disconnect signals from CapsLock service""" + try: + self.capslock.disconnect_by_func(self._on_caps_lock_state_changed) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/keyboard_layout.py b/src/window/osd/components/keyboard_layout.py index 45de256..b4f9b8e 100644 --- a/src/window/osd/components/keyboard_layout.py +++ b/src/window/osd/components/keyboard_layout.py @@ -37,3 +37,11 @@ class KeyboardLayoutOSDContainer(BaseOSDContainer): def update(self, *_): self.label.set_text(self.keyboard_layout.current_layout) super().update() + + def destroy(self): + """Disconnect signals from KeyboardLayout service""" + try: + self.keyboard_layout.disconnect_by_func(self._on_layout_changed) + except Exception: + pass + super().destroy() diff --git a/src/window/osd/components/microphone.py b/src/window/osd/components/microphone.py index a7fd590..461d6d9 100644 --- a/src/window/osd/components/microphone.py +++ b/src/window/osd/components/microphone.py @@ -98,3 +98,15 @@ class MicrophoneOSDContainer(BaseOSDContainer): def update(self, *_): self._update_display() super().update() + + def destroy(self): + """Disconnect signals from Audio service""" + try: + self.audio.disconnect_by_func(self._on_microphone_changed) + if self.audio.microphone: + self.audio.microphone.disconnect_by_func( + self._on_microphone_stream_changed + ) + except Exception: + pass + super().destroy() diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py index 458d648..b90c4e1 100644 --- a/src/window/panel/components/globalmenu.py +++ b/src/window/panel/components/globalmenu.py @@ -307,6 +307,32 @@ class GlobalMenuDropdowns: active_button = self.dropdown_button_map.get(dropdown_id) manage_button_style_classes(self.all_menu_buttons, active_button) + def destroy(self): + """Clean up all dropdowns and signal connections""" + try: + modus_service.disconnect_by_func(self._on_active_app_changed) + modus_service.disconnect_by_func(self.changed_dropdown) + modus_service.disconnect_by_func(self.hide_dropdowns) + except Exception: + pass + + # Destroy all dropdown captures + dropdown_captures = [ + getattr(self, "menu_button_dropdown", None), + getattr(self, "global_menu_title", None), + getattr(self, "global_menu_view", None), + getattr(self, "global_menu_window", None), + getattr(self, "global_menu_help", None), + ] + for capture in dropdown_captures: + try: + if capture and hasattr(capture, "destroy"): + capture.destroy() + except Exception: + pass + + super().destroy() + class GlobalMenu(Box): def __init__(self, parent_window=None, **kwargs): @@ -335,3 +361,12 @@ class GlobalMenu(Box): ) mouse_capture = self.dropdown_system.menu_button_dropdown mouse_capture.set_child_window_visible(not mouse_capture.is_visible()) + + def destroy(self): + """Clean up the global menu and its dropdowns""" + if hasattr(self, "dropdown_system") and self.dropdown_system: + try: + self.dropdown_system.destroy() + except Exception: + pass + super().destroy() diff --git a/src/window/panel/components/indicators.py b/src/window/panel/components/indicators.py index 2b9a462..95b2982 100644 --- a/src/window/panel/components/indicators.py +++ b/src/window/panel/components/indicators.py @@ -457,3 +457,102 @@ class BatteryIndicator(Box): def hide_controlcenter(self, *args): hide_control_center(self.battery_mousecapture) + + def destroy(self): + """Clean up resources when the indicator is destroyed.""" + # Disconnect signals + try: + modus_service.disconnect_by_func(self.on_battery_changed) + if hasattr(self, "battery_service") and self.battery_service: + self.battery_service.disconnect_by_func(self.on_battery_direct_changed) + except Exception: + pass + + # Destroy window and widget + try: + if hasattr(self, "battery_widget") and self.battery_widget: + self.battery_widget.destroy() + if hasattr(self, "battery_window") and self.battery_window: + self.battery_window.destroy() + if hasattr(self, "battery_mousecapture") and self.battery_mousecapture: + self.battery_mousecapture.destroy() + except Exception: + pass + + super().destroy() + + +# Add destroy methods to other classes in this file as well +def add_destroy_to_indicators(): + # BluetoothIndicator + + def bt_destroy(self): + try: + modus_service.disconnect_by_func(self.on_bluetooth_changed) + if hasattr(self, "bluetooth") and self.bluetooth: + self.bluetooth.disconnect_by_func(self.on_bluetooth_direct_changed) + self.bluetooth.disconnect_by_func(self.on_device_added) + self.bluetooth.disconnect_by_func(self.on_device_removed) + except Exception: + pass + try: + if hasattr(self, "bluetooth_widget") and self.bluetooth_widget: + self.bluetooth_widget.destroy() + if hasattr(self, "bluetooth_window") and self.bluetooth_window: + self.bluetooth_window.destroy() + if hasattr(self, "bluetooth_mousecapture") and self.bluetooth_mousecapture: + self.bluetooth_mousecapture.destroy() + except Exception: + pass + Box.destroy(self) + + BluetoothIndicator.destroy = bt_destroy + + # NetworkIndicator + def net_destroy(self): + try: + modus_service.disconnect_by_func(self.on_wlan_changed) + if hasattr(self, "network_service") and self.network_service: + self.network_service.disconnect_by_func(self.on_wifi_device_added) + self.network_service.disconnect_by_func(self.on_ethernet_device_added) + self.network_service.disconnect_by_func(self.on_network_changed) + + # Also disconnect from the devices themselves if they exist + if ( + hasattr(self.network_service, "wifi_device") + and self.network_service.wifi_device + ): + try: + self.network_service.wifi_device.disconnect_by_func( + self.on_network_direct_changed + ) + except Exception: + pass + if ( + hasattr(self.network_service, "ethernet_device") + and self.network_service.ethernet_device + ): + try: + self.network_service.ethernet_device.disconnect_by_func( + self.on_network_direct_changed + ) + except Exception: + pass + except Exception: + pass + try: + if hasattr(self, "wifi_widget") and self.wifi_widget: + self.wifi_widget.destroy() + if hasattr(self, "wifi_window") and self.wifi_window: + self.wifi_window.destroy() + if hasattr(self, "wifi_mousecapture") and self.wifi_mousecapture: + self.wifi_mousecapture.destroy() + except Exception: + pass + Box.destroy(self) + + NetworkIndicator.destroy = net_destroy + + +# Apply the destroy methods +add_destroy_to_indicators() diff --git a/src/window/panel/components/workspace.py b/src/window/panel/components/workspace.py index 8ceebc6..fffe298 100644 --- a/src/window/panel/components/workspace.py +++ b/src/window/panel/components/workspace.py @@ -69,7 +69,9 @@ class WorkspaceIndicator(Box): def update_config(self, new_config: dict): if "hide_special_workspace" in new_config: button_factory = self._get_button_factory() - self.remove(self.workspaces) + if hasattr(self, "workspaces") and self.workspaces: + self.workspaces.destroy() + self.workspaces = HyprlandWorkspaces( name="workspaces", spacing=4, @@ -77,3 +79,15 @@ class WorkspaceIndicator(Box): ) self.add(self.workspaces) self.workspaces.show_all() + + def destroy(self): + try: + from services.config import _config_handlers + + if self._on_config_changed in _config_handlers: + _config_handlers.remove(self._on_config_changed) + except Exception: + pass + if hasattr(self, "workspaces") and self.workspaces: + self.workspaces.destroy() + Box.destroy(self) diff --git a/src/window/panel/main.py b/src/window/panel/main.py index f0c55ab..d03229a 100644 --- a/src/window/panel/main.py +++ b/src/window/panel/main.py @@ -188,6 +188,14 @@ class Panel(Window): self.left_box.children = left_children # Indicators (create fresh instances on each rebuild) + # First, destroy old indicators to prevent memory leaks + for child in list(self.indicators.get_children()): + try: + child.destroy() + except Exception: + pass + self.indicators.children = [] + indicators_children = [] if get_config("battery", True): indicators_children.append(BatteryIndicator()) @@ -267,3 +275,36 @@ class Panel(Window): self.chevron_button.get_child().dynamic_file("misc/chevron-left.svg") else: self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg") + + def destroy(self): + """Clean up all signals and components""" + try: + modus_service.disconnect_by_func(self.on_dnd_changed) + notification_service.disconnect_by_func(self.on_notification_count_changed) + from services.config import _config_handlers + + if self._on_config_changed in _config_handlers: + _config_handlers.remove(self._on_config_changed) + except Exception: + pass + + # Destroy components + for component in [ + self.globalmenu, + self.workspace_indicator, + self.recording_indicator, + self.indicators, + ]: + try: + component.destroy() + except Exception: + pass + + # Destroy MouseCapture windows + for mc in [self.control_center, self.notification_center]: + try: + mc.destroy() + except Exception: + pass + + super().destroy() diff --git a/src/window/switcher.py b/src/window/switcher.py index c16b305..408589f 100644 --- a/src/window/switcher.py +++ b/src/window/switcher.py @@ -109,7 +109,7 @@ class ApplicationSwitcher(Window): def update_windows(self) -> None: for child in self.view.get_children(): - self.view.remove(child) + child.destroy() try: clients_data = self.conn.send_command("j/clients").reply