refactor! : drop subprocess usage and centralize brightness service initialization

This commit is contained in:
sankalp committed 2026-07-08 12:25:02 +05:45
1 parent 06be28535a
commit 5b8d6a62aa
21 files changed
+834 -828

No files matched your search

+3 -5
View File
@@ -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:
+26 -67
View File
@@ -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"""
+14 -73
View File
@@ -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,
),
],
)
+12 -3
View File
@@ -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:
+7 -5
View File
@@ -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:
+7
View File
@@ -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:
+3
View File
@@ -0,0 +1,3 @@
from .main import Dock
__all__ = ["Dock"]
+72
View File
@@ -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
File diff suppressed because it is too large. Load diff
+40
View File
@@ -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)
+94
View File
@@ -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
+163
View File
@@ -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()
+60 -30
View File
@@ -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:
+20 -12
View File
@@ -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()
+18 -3
View File
@@ -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}")
+8 -3
View File
@@ -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()
+2 -2
View File
@@ -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:
@@ -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):
+2 -3
View File
@@ -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()
+1 -2
View File
@@ -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
+20 -37
View File
@@ -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