perf: break animator and popup ref cycles, suspend idle timers

Break self -> animator -> lambda cycles in smooth switch and flat scale
by stopping animators and disconnecting value handlers on destroy. Drop
mutual OSD window/osd refs and clear the applet active-popup class ref.

Suspend the dock animation loop when nothing animates, pause the desktop
system info poller while unmapped, replace the OSD watchdog polling with
a single shot at expiry, lower the notch visualizer tick rate, and
cancel outstanding dock occlusion/rebuild timers on teardown.
This commit is contained in:
sankalp committed 2026-08-01 19:53:15 +05:45
1 parent d8696b9198
commit 456545e6a6
10 files changed
+152 -23

No files matched your search

+30 -3
View File
@@ -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()
+19 -1
View File
@@ -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
+5
View File
@@ -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:
+33 -5
View File
@@ -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(
+8
View File
@@ -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
+5 -1
View File
@@ -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
+19 -2
View File
@@ -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
+19 -10
View File
@@ -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, *_):
+13
View File
@@ -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()
+1 -1
View File
@@ -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