diff --git a/src/shared/widgets/flat_scale.py b/src/shared/widgets/flat_scale.py index dbe1aa2..2888a6c 100644 --- a/src/shared/widgets/flat_scale.py +++ b/src/shared/widgets/flat_scale.py @@ -156,7 +156,9 @@ class FlatScale(Gtk.DrawingArea, Widget): max_value=16.0, tick_widget=self, ) - self._anim_press.connect("notify::value", lambda *_: self.queue_draw()) + self._anim_press_handler = self._anim_press.connect( + "notify::value", lambda *_: self.queue_draw() + ) # Bubble open animator (0 → 1) self._anim_bubble_open = Animator( @@ -166,7 +168,9 @@ class FlatScale(Gtk.DrawingArea, Widget): max_value=1.0, tick_widget=self, ) - self._anim_bubble_open.connect("notify::value", lambda *_: self.queue_draw()) + self._anim_bubble_open_handler = self._anim_bubble_open.connect( + "notify::value", lambda *_: self.queue_draw() + ) # Bubble close animator (0 → 1, mapped as 1 - value) self._anim_bubble_close = Animator( @@ -176,7 +180,9 @@ class FlatScale(Gtk.DrawingArea, Widget): max_value=1.0, tick_widget=self, ) - self._anim_bubble_close.connect("notify::value", lambda *_: self.queue_draw()) + self._anim_bubble_close_handler = self._anim_bubble_close.connect( + "notify::value", lambda *_: self.queue_draw() + ) self._bubble_progress = 0.0 # 0.0 = hidden, 1.0 = fully visible self._bubble_closing = False @@ -459,6 +465,27 @@ class FlatScale(Gtk.DrawingArea, Widget): cr.restore() + def destroy(self): + """Stop all animators and break the self <-> animator reference cycles.""" + animators = [ + (self._anim_press, self._anim_press_handler), + (self._anim_bubble_open, self._anim_bubble_open_handler), + (self._anim_bubble_close, self._anim_bubble_close_handler), + ] + for animator, handler_id in animators: + try: + animator.stop() + except Exception: + pass + try: + animator.disconnect(handler_id) + except Exception: + pass + self._anim_press = None + self._anim_bubble_open = None + self._anim_bubble_close = None + super().destroy() + def do_draw(self, cr: cairo.Context) -> bool: styles = self.do_resolve_style() diff --git a/src/shared/widgets/smooth_switch.py b/src/shared/widgets/smooth_switch.py index bbaf206..8d2e570 100644 --- a/src/shared/widgets/smooth_switch.py +++ b/src/shared/widgets/smooth_switch.py @@ -56,7 +56,9 @@ class SmoothSwitch(Gtk.DrawingArea): if active: self._animator.value = 1.0 - self._animator.connect("notify::value", lambda *_: self.queue_draw()) + self._anim_notify_handler = self._animator.connect( + "notify::value", lambda *_: self.queue_draw() + ) self.add_events( Gdk.EventMask.BUTTON_PRESS_MASK @@ -95,6 +97,22 @@ class SmoothSwitch(Gtk.DrawingArea): self._animator.value = self._animator.min_value self._animator.play() + def destroy(self): + """Stop the animator and break the self <-> animator reference cycle.""" + if getattr(self, "_animator", None) is not None: + try: + self._animator.stop() + except Exception: + pass + if getattr(self, "_anim_notify_handler", None) is not None: + try: + self._animator.disconnect(self._anim_notify_handler) + except Exception: + pass + self._anim_notify_handler = None + self._animator = None + super().destroy() + def _on_draw(self, _, cr: cairo.Context): w, h = self._width, self._height r = h / 2 diff --git a/src/shared/window/applet_window.py b/src/shared/window/applet_window.py index 711bb0c..29fdd57 100644 --- a/src/shared/window/applet_window.py +++ b/src/shared/window/applet_window.py @@ -114,6 +114,11 @@ class AppletWindow(PopupWindow): self.close_applet() def destroy(self): + if AppletWindow._active_popup == self: + AppletWindow._active_popup = None + parent = getattr(self, "_parent_dropdown", None) + if parent is not None and getattr(parent, "_child_dropdown", None) is self: + parent._child_dropdown = None if hasattr(self, "dismiss_layer"): self.dismiss_layer.destroy() if self._hide_timeout_id is not None: diff --git a/src/window/desktop/system.py b/src/window/desktop/system.py index 99e95a1..be63b02 100644 --- a/src/window/desktop/system.py +++ b/src/window/desktop/system.py @@ -37,6 +37,9 @@ class SystemInfoBase(Box): all_visible=True, **kwargs, ) + self._system_timer_id = None + self.connect("map", self._on_map) + self.connect("unmap", self._on_unmap) self.progress = self.create_progress_bar(name="progress") self.main_label = Label( label="0%\nLoading", justification="center", name="progress-label" @@ -65,14 +68,39 @@ class SystemInfoBase(Box): ) def start_updates(self): - self.update() - self._system_timer_id = invoke_repeater(SYSTEM_UPDATE_INTERVAL, self.update) + self._start_update_timer() + + def _start_update_timer(self): + self._stop_update_timer() + if not self.get_mapped(): + return + self._system_timer_id = invoke_repeater( + SYSTEM_UPDATE_INTERVAL, self._update_tick + ) + + def _stop_update_timer(self): + if getattr(self, "_system_timer_id", None): + try: + GLib.source_remove(self._system_timer_id) + except Exception as e: + logger.error(f"Error removing system info timer: {e}") + self._system_timer_id = None + + def _update_tick(self) -> bool: + if not self.get_mapped(): + self._system_timer_id = None + return False + return self.update() + + def _on_map(self, *_): + self._start_update_timer() + + def _on_unmap(self, *_): + self._stop_update_timer() 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 + self._stop_update_timer() super().destroy() def create_info_line( diff --git a/src/window/dock/animator.py b/src/window/dock/animator.py index 90bb251..d352a5e 100644 --- a/src/window/dock/animator.py +++ b/src/window/dock/animator.py @@ -64,4 +64,12 @@ class DockAnimator: canvas._needs_redraw = False canvas.queue_draw() + # Self-pause: stop ticking once nothing is animating and no redraw is + # pending, so an idle dock consumes zero CPU. Restarted on mouse + # motion/enter, size changes or model rebuilds. + if not dirty and not canvas._needs_redraw: + self._running = False + self._timer_id = None + return False + return self._running diff --git a/src/window/dock/canvas.py b/src/window/dock/canvas.py index b84f5ff..33a71c1 100644 --- a/src/window/dock/canvas.py +++ b/src/window/dock/canvas.py @@ -163,6 +163,9 @@ class DockCanvas(Gtk.DrawingArea): def _on_size_allocate(self, widget, allocation) -> None: self._needs_redraw = True + animator = getattr(self, "animator", None) + if animator is not None: + animator.start() def do_get_preferred_height(self): h = self._canvas_height() @@ -327,6 +330,7 @@ class DockCanvas(Gtk.DrawingArea): self.model.items = new_items self._needs_redraw = True + self.animator.start() def _update_trash_item( self, @@ -459,7 +463,7 @@ class DockCanvas(Gtk.DrawingArea): 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) + self._dock_update_timer = GLib.timeout_add(100, self._do_update) def _do_update(self) -> bool: self._dock_update_timer = None diff --git a/src/window/dock/main.py b/src/window/dock/main.py index a8793f6..eda5621 100644 --- a/src/window/dock/main.py +++ b/src/window/dock/main.py @@ -57,6 +57,7 @@ class Dock(Window): self.is_hovered = False self.hide_ticket = 0 self._occlusion_timer_id = None + self._occlusion_check_pending = False self._hyprland_handlers = [] self._destroyed = False @@ -87,14 +88,15 @@ class Dock(Window): def _check_occlusion_deferred(self) -> None: """Debounce occlusion check to avoid rapid successive checks.""" - if hasattr(self, "_occlusion_check_pending") and self._occlusion_check_pending: + if self._occlusion_check_pending: return self._occlusion_check_pending = True - GLib.timeout_add(50, self._run_occlusion_check) + self._occlusion_timer_id = GLib.timeout_add(50, self._run_occlusion_check) def _run_occlusion_check(self) -> bool: """Run the actual occlusion check.""" self._occlusion_check_pending = False + self._occlusion_timer_id = None if self._destroyed: return False try: @@ -138,7 +140,9 @@ class Dock(Window): if config().get("dock.enabled", True): self.show() self.revealer.set_reveal_child(True) + self.canvas.animator.start() else: + self.canvas.animator.stop() self.hide() def on_hover_enter(self) -> None: @@ -165,6 +169,7 @@ class Dock(Window): def _disconnect_occlusion_signals(self) -> None: """Disconnect all Hyprland occlusion signal handlers.""" + self._cancel_occlusion_timer() hyprland = get_modus_service()._hyprland_connection for handler_id in self._hyprland_handlers: try: @@ -173,8 +178,20 @@ class Dock(Window): logger.warning(f"[Dock] Error disconnecting occlusion handler: {e}") self._hyprland_handlers.clear() + def _cancel_occlusion_timer(self) -> None: + if self._occlusion_timer_id is not None: + try: + GLib.source_remove(self._occlusion_timer_id) + except Exception as e: + logger.debug( + f"[Dock] Failed to remove occlusion timer {self._occlusion_timer_id}: {e}" + ) + self._occlusion_timer_id = None + self._occlusion_check_pending = False + def destroy(self) -> None: self._destroyed = True + self._cancel_occlusion_timer() self._disconnect_occlusion_signals() from services.config import off_config_change diff --git a/src/window/osd/components/base.py b/src/window/osd/components/base.py index 20bfeea..4c51dde 100644 --- a/src/window/osd/components/base.py +++ b/src/window/osd/components/base.py @@ -43,6 +43,8 @@ class BaseOSDContainer(Box): def _on_leave_notify(self, widget, event): if event.detail != Gdk.NotifyType.INFERIOR: self._is_hovered = False + if self.watchdog_handler and self.window.get_visible(): + self._schedule_watchdog() def is_hovered(self): return self._is_hovered @@ -92,20 +94,27 @@ class BaseOSDContainer(Box): self.window.hide() return False + def _schedule_watchdog(self) -> None: + """Arm a single-shot watchdog that hides the OSD at MAX_VISIBLE_TIME.""" + self.remove_watchdog_handler() + remaining = self.show_timestamp + self.MAX_VISIBLE_TIME - time.time() + delay_ms = max(50, int(remaining * 1000)) + self.watchdog_handler = GLib.timeout_add(delay_ms, self.watchdog_force_hide) + def watchdog_force_hide(self, *_): + self.watchdog_handler = 0 if not self.window.get_visible(): self.cleanup_all_handlers() return False - elapsed = time.time() - self.show_timestamp - if elapsed >= self.MAX_VISIBLE_TIME: - if not self.is_hovered(): - self.hide_window() - self.cleanup_all_handlers() - return False - else: - return True - return True + if self.is_hovered(): + # Keep the OSD visible while hovered; re-check periodically. + self.watchdog_handler = GLib.timeout_add(500, self.watchdog_force_hide) + return False + + self.hide_window() + self.cleanup_all_handlers() + return False def update(self, *_): if self._update_in_progress: @@ -130,7 +139,7 @@ class BaseOSDContainer(Box): self.focus() self.last_handler = invoke_repeater(1700, self.unfocus, initial_call=False) - self.watchdog_handler = GLib.timeout_add(1000, self.watchdog_force_hide) + self._schedule_watchdog() self._update_in_progress = False def focus(self, *_): diff --git a/src/window/osd/main.py b/src/window/osd/main.py index 14c291d..c462b4b 100644 --- a/src/window/osd/main.py +++ b/src/window/osd/main.py @@ -51,6 +51,11 @@ class OSD(Box): return self.show_container(self.containers.get("brightness")) + def destroy(self): + """Break the OSD <-> window reference cycle before teardown.""" + self.window = None + super().destroy() + class OSDWindow(Window): def __init__(self, **kwargs): @@ -81,3 +86,11 @@ class OSDWindow(Window): if not get_config("panel.osd", True): return self.osd.containers["brightness"].update() + + def destroy(self): + """Break the OSD <-> window reference cycle before teardown.""" + osd = getattr(self, "osd", None) + self.osd = None + if osd is not None: + osd.window = None + super().destroy() diff --git a/src/window/panel/notch/player.py b/src/window/panel/notch/player.py index 6f056b4..e3ed7b4 100644 --- a/src/window/panel/notch/player.py +++ b/src/window/panel/notch/player.py @@ -12,7 +12,7 @@ from utils.gtk_utils import svg_file # tunables _BARS = 8 # number of bars -_TICK_MS = 150 # ~7 fps — smooth with sine, very cheap +_TICK_MS = 300 # ~3 fps — wall-clock sine curve, cheaper sampling _H_MIN = 2 # minimum total bar height (px, both halves) _H_MAX = 16 # maximum total bar height (px) _BAR_W = 1 # bar pixel width