diff --git a/modules/controlcenter/battery.py b/modules/controlcenter/battery.py index 068de6e..33bfc8b 100644 --- a/modules/controlcenter/battery.py +++ b/modules/controlcenter/battery.py @@ -1,393 +1,615 @@ import subprocess -from fabric.utils import get_relative_path +from fabric.bluetooth import BluetoothClient, BluetoothDevice +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 from fabric.widgets.image import Image -from fabric.widgets.box import Box from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import GLib -from services.battery import Battery +from utils.utils import svg_file -class EnergyModeButton(Box): - def __init__( - self, - profile_name: str, - display_name: str, - icon_name: str, - battery_service: Battery, - parent, - **kwargs, - ): - super().__init__(name="energy-mode-button", **kwargs) - self.profile_name = profile_name - self.battery_service = battery_service - self.parent = parent +def get_battery_icon_file( + percentage: int, is_charging: bool, icon_path: str = "" +) -> str: + """ + Get the battery icon file path based on percentage and charging status. - self.mode_icon_svg = Svg( - # icon_name=f"battery-{icon_name}-symbolic", - svg_file=get_relative_path( - f"../../config/assets/icons/power_modes/battery-{icon_name}.svg" - ), - size=24, - ) - self.mode_icon = Box( - children=[self.mode_icon_svg], - name="energy-mode-icon", - style_classes="battery-profile-icon", - ) + Args: + percentage: Battery percentage (0-100) + is_charging: Whether the device is charging + icon_path: Base path for icons (unused, kept for compatibility) - self.mode_label = Label( - label=display_name, - style_classes="battery-power-mode", - h_align="start", - h_expand=True, - ) + Returns: + Relative path to the battery icon file + """ + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return svg_file(f"battery/{filename}") - start_box = Box( - orientation="horizontal", - spacing=4, - children=[self.mode_icon, self.mode_label], - ) - self.button = Button( - child=start_box, - h_expand=True, - name="energy-mode-button-clickable", - on_clicked=self.on_clicked, - style_classes="battery-profile-button", - ) +def set_bluetooth_enabled_with_fallback(client, enabled: bool): + try: + # Try fabric bluetooth first + client.set_enabled(enabled) + except Exception as e: + logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") - self.children = [self.button] - self.update_state() - - def on_clicked(self, *args): - success = self.battery_service.change_power_profile(self.profile_name) - if success: - # Update all profile buttons in parent - self.parent.update_energy_mode_buttons() - - # Reset icon state after short delay - GLib.timeout_add(300, lambda: self._reset_icon_state()) - - def _reset_icon_state(self): - return False # Remove timeout - - def update_state(self): - is_active = self.battery_service.power_profile == self.profile_name - if is_active: - self.mode_icon.add_style_class("connected") + # Fallback to rfkill to unblock/block bluetooth + if enabled: + command = "rfkill unblock bluetooth" else: - self.mode_icon.remove_style_class("connected") + command = "rfkill block bluetooth" + + result = exec_shell_command(command) + if result is False: + logger.error(f"rfkill fallback failed: command '{command}' returned False") + elif isinstance(result, str) and result.strip(): + # If result is a non-empty string, it might be an error message + logger.warning(f"rfkill command output: {result.strip()}") + else: + logger.info(f"rfkill fallback succeeded: {command}") -class GameModeButton(Box): - def __init__(self, parent, **kwargs): - super().__init__(name="energy-mode-button", h_expand=True, **kwargs) - self.parent = parent - - self.game_icon = Image( - icon_name="applications-games-symbolic", - size=16, - name="game-mode-icon", - style_classes="battery-gamemode-icon", +class BluetoothDeviceSlot(CenterBox): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(h_expand=True, name="device-button", **kwargs) + self.device = device + self.device.connect("changed", self.on_changed) + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() ) - self.game_label = Label( - label="Game Mode", - style_classes="gamemode-button", - h_align="start", - h_expand=True, + self.styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.dimage = Image( + icon_name=device.icon_name + "-symbolic", # type: ignore + size=5, + name="device-icon", + style_classes=" ".join(self.styles), ) - start_box = Box( - orientation="horizontal", - spacing=3, - children=[self.game_icon, self.game_label], + self.device_button = Button( + on_clicked=lambda *_: self.toggle_connecting(), + child=Box( + orientation="h", + h_expand=True, + children=[self.dimage, Label(label=device.name)], + ), # type: ignore ) + self.start_children = [self.device_button] - self.button = Button( - child=start_box, - name="game-mode-button-clickable", - on_clicked=self.on_clicked, - h_expand=True, - style_classes="battery-gamemode-button", - ) + # # Add battery info if available + if hasattr(device, "battery_percentage") and device.battery_percentage > 0: + battery_box = Box(orientation="h", spacing=4) - self.children = [self.button] - self.update_state() - - def on_clicked(self, *args): - try: - script_path = get_relative_path("../../scripts/gamemode.sh") - subprocess.run([script_path], check=False) - - GLib.timeout_add(500, lambda: self.update_state()) - except Exception as e: - print(f"Failed to toggle game mode: {e}") - - GLib.timeout_add(300, lambda: self._reset_icon_state()) - - def _reset_icon_state(self): - return False # Remove timeout - - def update_state(self): - try: - script_path = get_relative_path("../../scripts/gamemode.sh") - result = subprocess.run( - [script_path, "check"], capture_output=True, text=True, check=False + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + device.battery_percentage, + False, # Not charging for bluetooth devices + ) ) - is_active = result.stdout.strip() == "t" - if is_active: - self.game_icon.add_style_class("connected") - else: - self.game_icon.remove_style_class("connected") - except Exception as e: - print(f"Failed to check game mode status: {e}") - # Default to inactive state on error - self.game_icon.remove_style_class("connected") + # Create battery percentage label + battery_label = Label( + label=f"{device.battery_percentage:.0f}%", name="battery-label" + ) - return False # Remove timeout if called from GLib.timeout_add + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + + self.device_button.connect("enter-notify-event", self.on_button_enter) + self.device_button.connect("leave-notify-event", self.on_button_leave) + self.device.emit("changed") # to update display status + + def on_button_enter(self, widget, event): + self.add_style_class("button-hovered") + + def on_button_leave(self, widget, event): + self.remove_style_class("button-hovered") + + def toggle_connecting(self): + self.device.emit("changed") + self.device.set_connecting(not self.device.connected) + + def on_changed(self, *_): + try: + # Update connection and pairing status + new_styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.styles = new_styles + self.dimage.set_property("style-classes", " ".join(self.styles)) + except Exception: + return + + # Update battery info if available + if ( + hasattr(self.device, "battery_percentage") + and self.device.battery_percentage > 0 + ): + if not self.end_children: # Add battery display if not already present + battery_box = Box(orientation="h", spacing=4) + + # Create battery icon + battery_icon = svg_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Create battery percentage label + battery_label = Label( + label=f"{self.device.battery_percentage:.0f}%", name="battery-label" + ) + + battery_box.children = [battery_icon, battery_label] + self.end_children = [battery_box] + else: # Update existing battery display + battery_box = self.end_children[0] + if hasattr(battery_box, "children") and len(battery_box.children) >= 2: + battery_icon = battery_box.children[0] + battery_label = battery_box.children[1] + + # Update battery icon + battery_icon.dynamic_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) + ) + + # Update battery percentage + battery_label.set_label(f"{self.device.battery_percentage:.0f}%") + elif self.end_children: # Remove battery display if no longer available + self.end_children = [] + + return -class BatteryControl(Box): - def __init__(self, parent, **kwargs): +class BluetoothConnections(Box): + def __init__( + self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs + ): super().__init__( - spacing=12, + spacing=8, orientation="vertical", - name="control-center-widgets", + name="bluetooth-connections", **kwargs, ) - self.set_size_request(354, -1) self.parent = parent - self.battery_service = Battery() - self.energy_mode_buttons = [] + self.show_hidden_devices = show_hidden_devices + self.is_scanning = False # Track scanning state + self.refresh_timer = None # Timer for periodic device refresh + self._update_in_progress = False # Prevent concurrent updates + self._destroyed = False # Track if widget is destroyed - self.battery_widget = Box( - name="battery-widget", - orientation="vertical", - style_classes="battery-status-section", - h_expand=True, - spacing=8, + self.client = BluetoothClient(on_device_added=self.on_device_added) + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="bluetooth-refresh-indicator", + label="↓ Pull to scan for devices", + h_align="center", + visible=False, + style="color: #fff; font-size: 12px; padding: 5px;", ) - self.battery_title = Label( - label="Battery", style_classes="battery-main-title", h_align="start" - ) - - self.battery_percentage_label = Label( - label="80%", style_classes="battery-percentage", h_align="end" - ) - - self.battery_header = CenterBox( - start_children=self.battery_title, - end_children=self.battery_percentage_label, - name="battery-header", - ) - - self.power_source_label = Label( - label="Power Source: Power Adapter", - style_classes="battery-power-source", - h_align="start", - ) - - self.charging_time_label = Label( - label="1h 4m until fully charged", - style_classes="battery-power-source", - h_align="start", - ) - - self.energy_mode_section = Box( - orientation="vertical", spacing=8, name="energy-mode-section" - ) - - self.energy_mode_title = Label( - label="Energy Mode", style_classes="battery-section-title", h_align="start" - ) - - self.energy_modes_container = Box( - orientation="vertical", spacing=4, name="energy-modes-container" - ) - - self.game_mode_section = Box( - orientation="vertical", spacing=8, name="game-mode-section" - ) - - self.game_mode_title = Label( - label="Game Mode", style_classes="battery-section-title", h_align="start" - ) - - self.game_mode_container = Box( - orientation="vertical", spacing=4, name="game-mode-container" - ) - - self.battery_settings_button = Button( - v_align="center", - child=Label( - label="Battery Settings", - h_align="start", - ), - style_classes="battery-settings-button", - on_clicked=self.open_battery_settings, - ) - - self.battery_widget.add(self.battery_header) - self.battery_widget.add(self.power_source_label) - self.battery_widget.add(self.charging_time_label) - - separator1 = Separator(orientation="h", name="separator") - self.battery_widget.add(separator1) - - self.energy_mode_section.add(self.energy_mode_title) - self.energy_mode_section.add(self.energy_modes_container) - self.battery_widget.add(self.energy_mode_section) - - separator2 = Separator(orientation="h", name="separator") - self.battery_widget.add(separator2) - - self.game_mode_section.add(self.game_mode_title) - self.game_mode_section.add(self.game_mode_container) - self.battery_widget.add(self.game_mode_section) - - separator3 = Separator(orientation="h", name="separator") - self.battery_widget.add(separator3) - - self.battery_widget.add(self.battery_settings_button) - - self.add(self.battery_widget) - - self.battery_service.connect("changed", self.on_battery_changed) - self.battery_service.connect("profile_changed", self.on_profile_changed) - - # Initialize display - self.update_battery_info() - self.create_energy_mode_buttons() - self.create_game_mode_button() - - def open_battery_settings(self, *args): - # TODO: Implement to open Battery Settings - pass - - def create_energy_mode_buttons(self): - # Clear existing buttons - for button in self.energy_mode_buttons: - button.destroy() - self.energy_mode_buttons.clear() - - # Get available profiles - available_profiles = self.battery_service.available_profiles - - if not available_profiles: - no_profiles_label = Label( - label="No energy modes available", - style_classes="battery-no-profiles", - h_align="start", - ) - self.energy_modes_container.add(no_profiles_label) - return - - # Define energy mode mappings with proper icon names - energy_mode_config = { - "balanced": {"display": "Automatic", "icon": "balanced"}, - "power-saver": {"display": "Low Power", "icon": "power"}, - "powersave": {"display": "Low Power", "icon": "power"}, - "performance": {"display": "High Power", "icon": "performance"}, - } - - # Define the desired order for energy modes - desired_order = ["balanced", "power-saver", "powersave", "performance"] - - # Create ordered list of available profiles - ordered_profiles = [] - for profile_name in desired_order: - if profile_name in available_profiles: - ordered_profiles.append(profile_name) - - # Add any remaining profiles not in the desired order - for profile in available_profiles: - if profile not in ordered_profiles: - ordered_profiles.append(profile) - - # Create button for each available profile in the specified order - for profile in ordered_profiles: - config = energy_mode_config.get( - profile, {"display": profile.title(), "icon": "good"} - ) - - button = EnergyModeButton( - profile_name=profile, - display_name=config["display"], - icon_name=config["icon"], - battery_service=self.battery_service, - parent=self, - ) - self.energy_mode_buttons.append(button) - self.energy_modes_container.add(button) - - def update_energy_mode_buttons(self): - for button in self.energy_mode_buttons: - button.update_state() - - def create_game_mode_button(self): - # Clear existing game mode button if any - for child in list(self.game_mode_container.get_children()): - child.destroy() - - # Create game mode button - self.game_mode_button = GameModeButton(parent=self) - self.game_mode_container.add(self.game_mode_button) - - def update_battery_info(self): - if not self.battery_service.is_present: - self.battery_percentage_label.set_label("No Battery") - self.power_source_label.set_label("Power Source: Not Present") - self.charging_time_label.set_label("") - return - - # Update percentage in header - percentage = self.battery_service.percentage - self.battery_percentage_label.set_label(f"{percentage}%") - - # Update power source and charging info - state = self.battery_service.state - - if state in ["CHARGING", "PENDING_CHARGE"]: - self.power_source_label.set_label("Power Source: Power Adapter") - time_to_full = self.battery_service.time_to_full - if time_to_full != "N/A" and time_to_full != "0m": - self.charging_time_label.set_label( - f"{time_to_full} until fully charged" + # Create title with optional back button + title_children = [] + if show_back_button: + title_children.append( + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_bluetooth(), ) + ) + title_children.append(Label("Bluetooth", name="bluetooth-title")) + + self.title = Box( + orientation="h", + children=title_children, + ) + + self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + + # Safely set initial state + self.toggle_button.set_active(self.client.enabled) + self.toggle_button.connect( + "notify::active", + lambda *_: set_bluetooth_enabled_with_fallback( + self.client, self.toggle_button.get_active() + ), + ) + + # Connect client signals + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_active(self.client.enabled), + ) + self.client.connect("notify::scanning", lambda *_: self.update_scan_label()) + + # Connect to device changes + self.client.connect("device-added", self.update_devices) + self.client.connect("device-removed", self.update_devices) + + # Connect to additional signals for better real-time monitoring + self.client.connect("changed", self.on_client_changed) + + # Create Devices section + self.paired_devices_label = Label( + label="Devices", h_align="start", name="networks-title" + ) + self.paired_devices = Box( + spacing=4, orientation="vertical", name="known-networks" + ) + + # Create "No devices available" message + self.no_devices_label = Label( + label="No devices available", + h_align="center", + name="no-networks-label", + visible=False, + ) + + # Create Other Devices section with clickable title + self.other_devices_button = Button( + child=Label("Other Devices", h_align="start"), + name="wifi-other-button", + on_clicked=self.toggle_other_devices, + ) + self.other_devices = Box(spacing=4, orientation="vertical") + + # Create scrolled window for other devices + self.other_devices_scrolled = ScrolledWindow( + min_content_size=(303, 150), + child=self.other_devices, + overlay_scroll=True, + ) + + # Add pull-to-refresh functionality to scrolled window + self.setup_pull_to_refresh() + + # Create revealer for Other Devices section + self.other_devices_revealer = Revealer( + child=self.other_devices_scrolled, + transition_type="slide-down", + transition_duration=100, + child_revealed=False, + ) + + # Create More Settings button (same style as Other Devices button) + self.more_settings_button = Button( + child=Label("More Settings", h_align="start"), + name="wifi-other-button", + on_clicked=self.open_bluetooth_settings, + ) + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="bluetooth-widget-top", + ), + self.refresh_indicator, + Separator(orientation="h", name="separator"), + self.paired_devices_label, + self.paired_devices, + self.no_devices_label, + Separator(orientation="h", name="separator"), + self.other_devices_button, + self.other_devices_revealer, + Separator(orientation="h", name="separator"), + self.more_settings_button, + ] + + # Connect cleanup on destroy + self.connect("destroy", self.on_destroy) + + self.client.notify("scanning") + self.client.notify("enabled") + + # Initial device update + self.update_devices() + + # Start periodic device monitoring for real-time updates + self.start_device_monitoring() + + def toggle_other_devices(self, *_): + """Toggle the visibility of other devices section""" + current_state = self.other_devices_revealer.child_revealed + self.other_devices_revealer.child_revealed = not current_state + + # Handle scanning based on section visibility + if self.client: + if self.other_devices_revealer.child_revealed: + # Start scanning when revealing other devices section + if not self.client.scanning: + self.client.toggle_scan() + # Also force an immediate device refresh to catch any missed connections + self.force_device_refresh() else: - self.charging_time_label.set_label("Charging...") - elif state == "FULLY_CHARGED": - self.power_source_label.set_label("Power Source: Power Adapter") - self.charging_time_label.set_label("Fully Charged") - elif state in ["DISCHARGING", "PENDING_DISCHARGE"]: - self.power_source_label.set_label("Power Source: Battery") - time_to_empty = self.battery_service.time_to_empty - if time_to_empty != "N/A" and not time_to_empty.startswith( - "4553h" - ): # Filter out unrealistic times - self.charging_time_label.set_label(f"{time_to_empty} remaining") - else: - self.charging_time_label.set_label("On Battery Power") - elif state == "EMPTY": - self.power_source_label.set_label("Power Source: Battery") - self.charging_time_label.set_label("Battery Empty") + # Stop scanning when hiding other devices section + if self.client.scanning: + self.client.toggle_scan() + + def open_bluetooth_settings(self, *_): + """Open Blueman bluetooth manager""" + try: + subprocess.Popen(["blueman-manager"], start_new_session=True) + if self.parent and hasattr(self.parent, "hide_controlcenter"): + self.parent.hide_controlcenter() + except FileNotFoundError: + pass + except Exception: + pass + + def update_scan_label(self): + """Update scanning state appearance""" + if self.client.scanning: + # Show scanning feedback in refresh indicator + self.refresh_indicator.set_label("Scanning for devices...") + self.refresh_indicator.set_visible(True) + self.refresh_indicator.add_style_class("scanning") else: - self.power_source_label.set_label("Power Source: Unknown") - self.charging_time_label.set_label("") + # Hide scanning feedback + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("scanning") - def on_battery_changed(self, *args): - self.update_battery_info() + def update_devices(self, *_): + """Update the list of available devices""" + # Prevent concurrent updates and check if destroyed + if self._update_in_progress or self._destroyed or not self.client: + return - def on_profile_changed(self, service, new_profile): - self.update_energy_mode_buttons() + self._update_in_progress = True + + try: + # Store current device addresses to detect changes + current_paired_addresses = { + child.device.address + for child in self.paired_devices.get_children() + if hasattr(child, "device") + } + current_other_addresses = { + child.device.address + for child in self.other_devices.get_children() + if hasattr(child, "device") + } + + # Get current devices safely + devices = self.client.devices + paired_devices = [] + other_devices = [] + new_paired_addresses = set() + new_other_addresses = set() + + for device in devices: + try: + if device.name and device.name != "Unknown": + # Categorize devices: paired devices go to "Devices (Paired)" + # All others go to "Other Devices" + if device.paired: + paired_devices.append(device) + new_paired_addresses.add(device.address) + else: + other_devices.append(device) + new_other_addresses.add(device.address) + except Exception: + continue + + # Check if we need to update (devices added/removed) + paired_changed = current_paired_addresses != new_paired_addresses + other_changed = current_other_addresses != new_other_addresses + + # Only rebuild if something actually changed + if paired_changed or other_changed: + # Clear existing devices safely + for child in list(self.paired_devices.get_children()): + if not self._destroyed: + child.destroy() + for child in list(self.other_devices.get_children()): + if not self._destroyed: + child.destroy() + + # Add paired devices + for device in paired_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.paired_devices.add(device_slot) + + # Add other devices + for device in other_devices: + if not self._destroyed: + device_slot = BluetoothDeviceSlot(device) + self.other_devices.add(device_slot) + + # Show/hide sections based on available devices + if not self._destroyed: + has_paired_devices = len(paired_devices) > 0 + has_other_devices = len(other_devices) > 0 + has_any_devices = has_paired_devices or has_other_devices + + # Show paired devices section only if there are paired devices + self.paired_devices_label.set_visible(has_paired_devices) + self.paired_devices.set_visible(has_paired_devices) + + # Show "No devices available" message if no devices at all + self.no_devices_label.set_visible(not has_any_devices) + + # Always show the other devices button, regardless of available devices + self.other_devices_button.set_visible(True) # Always visible + + except Exception: + pass + finally: + self._update_in_progress = False + + def start_device_monitoring(self): + """Start periodic monitoring for device changes""" + # Monitor for device changes every 5 seconds (less aggressive) + # This helps catch devices that connect from external sources + self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_device_refresh) + + def stop_device_monitoring(self): + """Stop periodic monitoring""" + if self.refresh_timer: + GLib.source_remove(self.refresh_timer) + self.refresh_timer = None + + def periodic_device_refresh(self): + """Periodically refresh device list to catch external connections""" + # Skip if update in progress, destroyed, or client not available + if ( + self._update_in_progress + or self._destroyed + or not self.client + or not self.client.enabled + ): + return True # Continue monitoring + + try: + # Simple check - just trigger update_devices which has its own safety checks + # Don't force signal emissions as that can cause race conditions + self.update_devices() + + except Exception: + pass + + return True # Continue monitoring + + def force_device_refresh(self): + """Force an immediate refresh of the device list""" + if self._update_in_progress or self._destroyed: + return + + try: + # Simply trigger update_devices which has its own safety checks + # Avoid forcing signal emissions to prevent race conditions + self.update_devices() + except Exception: + pass + + def on_client_changed(self, *_): + """Handle when the bluetooth client state changes""" + # Update devices when client state changes + self.update_devices() + + def on_destroy(self, widget): + """Cleanup when widget is destroyed""" + # Mark as destroyed to prevent further updates + self._destroyed = True + # Stop monitoring + self.stop_device_monitoring() + # Make sure other devices revealer is collapsed when closing + try: + self.other_devices_revealer.child_revealed = False + except: + pass # Widget might already be destroyed + + def close_bluetooth(self): + """Called when Bluetooth panel is being closed""" + # Collapse the other devices section when closing + self.other_devices_revealer.child_revealed = False + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + # Get the scrolled window's vertical adjustment + self.vadjustment = self.other_devices_scrolled.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Connect to scroll events + self.other_devices_scrolled.connect("scroll-event", self.on_scroll_event) + self.other_devices_scrolled.connect("button-press-event", self.on_button_press) + self.other_devices_scrolled.connect( + "button-release-event", self.on_button_release + ) + self.other_devices_scrolled.connect( + "motion-notify-event", self.on_motion_notify + ) + + # Enable events + self.other_devices_scrolled.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + # Only handle pull-to-refresh when at the top + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + # Scrolling up at the top - toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + return True # Consume the event + return False # Let normal scrolling continue + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + # Toggle scan and force refresh + self.client.toggle_scan() + self.force_device_refresh() + # Hide refresh indicator + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + # Show refresh indicator when pulling down + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + if self.client.scanning: + self.refresh_indicator.set_label("↑ Release to stop scanning") + else: + self.refresh_indicator.set_label("↑ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + if self.client.scanning: + self.refresh_indicator.set_label("↓ Pull to stop scanning") + else: + self.refresh_indicator.set_label("↓ Pull to scan for devices") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def on_device_added(self, client: BluetoothClient, address: str): + """Handle when a new device is added""" + # Update the device list when devices are added + self.update_devices() diff --git a/modules/controlcenter/bluetooth.py b/modules/controlcenter/bluetooth.py index 08346aa..33bfc8b 100644 --- a/modules/controlcenter/bluetooth.py +++ b/modules/controlcenter/bluetooth.py @@ -1,8 +1,7 @@ import subprocess -import gi from fabric.bluetooth import BluetoothClient, BluetoothDevice -from fabric.utils import get_relative_path +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 @@ -11,14 +10,28 @@ from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.separator import Separator -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib, Gtk -from loguru import logger -from services.battery import Battery +from utils.utils import svg_file -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") + +def get_battery_icon_file( + percentage: int, is_charging: bool, icon_path: str = "" +) -> str: + """ + Get the battery icon file path based on percentage and charging status. + + Args: + percentage: Battery percentage (0-100) + is_charging: Whether the device is charging + icon_path: Base path for icons (unused, kept for compatibility) + + Returns: + Relative path to the battery icon file + """ + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return svg_file(f"battery/{filename}") def set_bluetooth_enabled_with_fallback(client, enabled: bool): @@ -27,19 +40,21 @@ def set_bluetooth_enabled_with_fallback(client, enabled: bool): client.set_enabled(enabled) except Exception as e: logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}") + # Fallback to rfkill to unblock/block bluetooth if enabled: - command = ["rfkill", "unblock", "bluetooth"] + command = "rfkill unblock bluetooth" else: - command = ["rfkill", "block", "bluetooth"] + command = "rfkill block bluetooth" - try: - # Execute the rfkill command - subprocess.run( - command, capture_output=True, text=True, timeout=10, check=True - ) - except Exception as subprocess_error: - logger.error(f"rfkill fallback failed with exception: {subprocess_error}") + result = exec_shell_command(command) + if result is False: + logger.error(f"rfkill fallback failed: command '{command}' returned False") + elif isinstance(result, str) and result.strip(): + # If result is a non-empty string, it might be an error message + logger.warning(f"rfkill command output: {result.strip()}") + else: + logger.info(f"rfkill fallback succeeded: {command}") class BluetoothDeviceSlot(CenterBox): @@ -73,21 +88,16 @@ class BluetoothDeviceSlot(CenterBox): ) self.start_children = [self.device_button] - # Add battery info if available + # # Add battery info if available if hasattr(device, "battery_percentage") and device.battery_percentage > 0: battery_box = Box(orientation="h", spacing=4) # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", + battery_icon = svg_file( + get_battery_icon_file( + device.battery_percentage, + False, # Not charging for bluetooth devices + ) ) # Create battery percentage label @@ -134,16 +144,11 @@ class BluetoothDeviceSlot(CenterBox): battery_box = Box(orientation="h", spacing=4) # Create battery icon - battery_icon = Svg( - size=16, - svg_file=get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) - ), - name="battery-icon", + battery_icon = svg_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices + ) ) # Create battery percentage label @@ -160,13 +165,10 @@ class BluetoothDeviceSlot(CenterBox): battery_label = battery_box.children[1] # Update battery icon - battery_icon.set_from_file( - get_relative_path( - Battery.get_battery_icon_file( - self.device.battery_percentage, - False, # Not charging for bluetooth devices - "../../config/assets/icons/", - ) + battery_icon.dynamic_file( + get_battery_icon_file( + self.device.battery_percentage, + False, # Not charging for bluetooth devices ) ) diff --git a/modules/controlcenter/expanded_player.py b/modules/controlcenter/expanded_player.py index 99d0ad4..b7a2514 100644 --- a/modules/controlcenter/expanded_player.py +++ b/modules/controlcenter/expanded_player.py @@ -1,32 +1,33 @@ # Standard library imports +import gc import os import re import tempfile +import threading import urllib.parse import urllib.request -import threading import weakref -from typing import List, Optional, Dict, Set -import gc +from typing import Dict, List, Optional + +from fabric.utils import bulk_connect, cooldown, invoke_repeater +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.overlay import Overlay # Fabric imports from fabric.widgets.scale import Scale -from widgets.wayland import WaylandWindow as Window -from fabric.widgets.button import Button -from fabric.widgets.label import Label -from fabric.widgets.box import Box -from fabric.utils import bulk_connect, invoke_repeater, cooldown -from fabric.utils.helpers import get_relative_path -from fabric.widgets.image import Image -from fabric.widgets.overlay import Overlay from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg +from fabric.widgets.wayland import WaylandWindow as Window from gi.repository import GLib, GObject from loguru import logger +import config.data as data + # Local imports from services.mpris import MprisPlayer, MprisPlayerManager -import config.data as data +from utils.utils import svg_file CACHE_DIR = f"{data.CACHE_DIR}/media" @@ -46,18 +47,18 @@ def get_shared_mpris_manager(): def cleanup_artwork_cache(): - """Clean up artwork cache to prevent memory leaks.""" + """Clean up artwork cache immediately when size limit is reached.""" global _artwork_cache if len(_artwork_cache) > _max_artwork_cache_size: - # Keep only the most recent items + # Keep only the most recent items immediately items = list(_artwork_cache.items()) _artwork_cache = dict(items[-_max_artwork_cache_size:]) - # Force garbage collection + # Immediate garbage collection gc.collect() def cleanup_old_cache_files(): - """Clean up old artwork cache files aggressively to save memory.""" + """Clean up old artwork cache files immediately (no lazy loading).""" try: if not os.path.exists(CACHE_DIR): return @@ -92,7 +93,7 @@ def get_artwork_cached(url: str) -> Optional[str]: if url in _artwork_cache: return _artwork_cache[url] - # Clean cache if too large + # Clean cache immediately if too large (no lazy loading) cleanup_artwork_cache() try: @@ -125,18 +126,6 @@ def get_artwork_cached(url: str) -> Optional[str]: logger.warning(f"Failed to cache artwork from {url}: {e}") return None - for filename in os.listdir(CACHE_DIR): - filepath = os.path.join(CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < six_hours_ago: - os.unlink(filepath) - except Exception: - pass # Ignore individual file errors - except Exception: - pass # Ignore all errors in cleanup - class EmbeddedExpandedPlayer(Box): """Embedded expanded player widget for use inside control center.""" @@ -162,15 +151,6 @@ class EmbeddedExpandedPlayer(Box): # Create expanded player content self.player_content = PlayerBoxStack(self.mpris_manager) - # Add escape key binding for navigation back - self._keybinding_added = False - try: - if hasattr(self.control_center, "add_keybinding"): - self.control_center.add_keybinding("Escape", self._on_back_clicked) - self._keybinding_added = True - except Exception: - pass # Ignore if keybinding fails - self.children = [ Box( orientation="horizontal", @@ -221,14 +201,6 @@ class EmbeddedExpandedPlayer(Box): except Exception as e: logger.warning(f"Failed to destroy child widget: {e}") - # Clean up keybinding if it was added - if hasattr(self, "_keybinding_added") and self._keybinding_added: - try: - if hasattr(self.control_center, "remove_keybinding"): - self.control_center.remove_keybinding("Escape") - except Exception as e: - logger.warning(f"Failed to remove keybinding: {e}") - # Aggressively clean global caches global _widget_cache, _artwork_cache _widget_cache.clear() @@ -250,35 +222,12 @@ class EmbeddedExpandedPlayer(Box): finally: super().destroy() - def _periodic_cleanup(self): - """Light cleanup for EmbeddedExpandedPlayer reuse""" - try: - logger.debug("🧹 EmbeddedExpandedPlayer light cleanup starting") - - # Only clean up the player content lightly - if hasattr(self, "player_content") and hasattr( - self.player_content, "_periodic_cleanup" - ): - self.player_content._periodic_cleanup() - - # Clean global caches but don't destroy core functionality - global _artwork_cache - _artwork_cache.clear() - - # Light garbage collection - gc.collect() - - logger.debug("🧹 EmbeddedExpandedPlayer light cleanup completed") - - except Exception as e: - logger.warning(f"Error during EmbeddedExpandedPlayer light cleanup: {e}") - class PlayerBoxStack(Box): """Memory-optimized widget that displays current player information.""" def __init__(self, mpris_manager: MprisPlayerManager, **kwargs): - # Clean up old cache files on startup + # Clean up old cache files immediately (no lazy loading) cleanup_old_cache_files() # The player stack with memory-efficient settings @@ -319,63 +268,16 @@ class PlayerBoxStack(Box): self._signal_connections.append((self.mpris_manager, handler_id)) # Process existing players - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}" - ) + for player in self.mpris_manager.players.values(): # type: ignore + logger.info(f"[PLAYER MANAGER] player found: {player.player_name}") self.on_new_player(self.mpris_manager, player) - # Schedule periodic memory cleanup and store source ID - self._cleanup_source_id = GLib.timeout_add_seconds( - 300, self._periodic_cleanup - ) # Every 5 minutes - - def _periodic_cleanup(self): - """Light cleanup for widget reuse - preserve functionality.""" - try: - logger.debug("Starting light expanded player cleanup for reuse") - - # Clean artwork cache to reduce memory - cleanup_artwork_cache() - - # Reset visual state but preserve connections and core functionality - # Only clear the player widgets that can be recreated - for widget in list(self._player_widgets.values()): - try: - if widget and hasattr(widget, "get_parent") and widget.get_parent(): - # Only remove from parent, don't destroy (let GTK handle it) - widget.get_parent().remove(widget) - except Exception: - pass - # Don't clear the _player_widgets dict - just let them be recreated - - # Reset stack to show no_media_box without destroying children - try: - if hasattr(self, "player_stack") and hasattr(self, "no_media_box"): - self.player_stack.set_visible_child(self.no_media_box) - self.current_stack_pos = 0 - except Exception: - pass - - # Light garbage collection - gc.collect() - - logger.debug("Light expanded player cleanup completed") - - except Exception as e: - logger.warning(f"Error during light cleanup: {e}") - - return True # Continue timer + # No periodic cleanup - immediate cleanup when needed def destroy(self): """Clean up resources when the widget is destroyed.""" try: - # Cancel any pending cleanup timer - if hasattr(self, "_cleanup_source_id") and self._cleanup_source_id: - try: - GLib.source_remove(self._cleanup_source_id) - except Exception: - pass # Timer may have already been removed + # No periodic cleanup timer to cancel (removed lazy loading) # Disconnect all signal connections for obj, handler_id in self._signal_connections: @@ -576,7 +478,7 @@ class PlayerBoxStack(Box): self.set_visible(True) - new_player_box = PlayerBox(player=MprisPlayer(player), player_stack=self) + new_player_box = PlayerBox(player=player, player_stack=self) self.player_stack.children = [ *self.player_stack.children, new_player_box, @@ -602,7 +504,7 @@ class PlayerBoxStack(Box): for player_box in players: if ( hasattr(player_box, "player") - and player_box.player.player_name == player_name + and player_box.player.bus_name == player_name ): player_box_to_remove = player_box break @@ -789,7 +691,7 @@ class PlayerBox(Box): self.seek_bar.connect("value-changed", self._on_scale_value_changed) self.seek_bar.connect("button-press-event", self._on_seek_start) self.seek_bar.connect("button-release-event", self._on_seek_end) - self.player.bind("can-seek", "sensitive", self.seek_bar) + self.player.bind_property("can_seek", self.seek_bar, "sensitive") # Position and length labels for seek bar self.position_label = Label( @@ -878,7 +780,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 Artist" + re.sub(r"\r?\n", " ", ", ".join(x)) + if isinstance(x, list) and len(x) > 0 + else "No Artist" ), # type: ignore ) self.player.bind_property( @@ -904,21 +808,9 @@ class PlayerBox(Box): self.stack_buttons_box.hide() # Initially hidden # Create SVG icons from player directory - self.skip_next_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.skip_prev_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Rewind.svg"), - ) - self.play_pause_icon = Svg( - name="btn", - style_classes=["control-buttons"], - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) + self.skip_next_icon = svg_file("player/fwd.svg", size=22) + self.skip_prev_icon = svg_file("player/Rewind.svg", size=22) + self.play_pause_icon = svg_file("player/Pause.svg", size=22) self.play_pause_button = Button( style_classes=["control-buttons"], @@ -943,6 +835,7 @@ class PlayerBox(Box): style_classes=["control-buttons"], on_clicked=self._on_player_prev, ) + self.player.bind_property("can_go_previous", self.prev_button, "sensitive") self.button_box.children = ( self.prev_button, self.play_pause_button, @@ -989,7 +882,7 @@ class PlayerBox(Box): connections = bulk_connect( self.player, { - "exit": self._on_player_exit, + "closed": self._on_player_exit, "notify::playback-status": self._on_playback_change, "notify::metadata": self._on_metadata, }, @@ -998,6 +891,12 @@ class PlayerBox(Box): for handler_id in connections: self._signal_connections.append((self.player, handler_id)) + # Start seek bar timer immediately for live updates (regardless of playback status) + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) + + # Connect to realize signal to initialize seek bar properly + self.seek_bar.connect("realize", self._on_seek_bar_realized) + def destroy(self): """Clean up all resources when the widget is destroyed.""" # Set exit flag FIRST to stop any running timers @@ -1119,14 +1018,24 @@ class PlayerBox(Box): self._set_image() duration = self.player.length - if duration: + if duration is None: + duration = 0 + + if duration and duration > 0: self.length_label.set_label(self.length_str(duration)) # Clamp duration to avoid 32-bit integer overflow in the scale widget max_int32 = 2147483647 # 2^31 - 1 safe_duration = min(max_int32, duration) - self.seek_bar.set_range(0, safe_duration) - # Cancel existing timer before starting a new one + # Only set range if seek bar is ready + if self.seek_bar.get_realized(): + self.seek_bar.set_range(0, safe_duration) + else: + self.length_label.set_label("0:00") + if self.seek_bar.get_realized(): + self.seek_bar.set_range(0, 100) + + # Restart timer to ensure it's running with updated metadata if self._seekbar_timer_id: try: from gi.repository import GLib @@ -1161,17 +1070,17 @@ class PlayerBox(Box): self.player.previous() def _on_playback_change(self, player, status): - status = player.get_property("playback-status") + status = player.playback_status - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) + if status == "Paused": + self.play_pause_icon.dynamic_file("player/play.svg") + # Keep timer running for live updates even when paused - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) + if status == "Playing": + self.play_pause_icon.dynamic_file("player/Pause.svg") + # Ensure timer is running for live updates + if not self._seekbar_timer_id: + self._seekbar_timer_id = invoke_repeater(1000, self._move_seekbar) def _update_image(self, image_path): if image_path and os.path.isfile(image_path): @@ -1292,7 +1201,14 @@ class PlayerBox(Box): return False # Stop the timer try: + # Check if seek bar is realized and has a valid adjustment + if not self.seek_bar.get_realized(): + return True # Continue timer, widget not ready yet + position = self.player.position + if position is None: + position = 0 + self.position_label.set_label(self.length_str(position)) # Only update seek bar if user is not currently seeking @@ -1300,7 +1216,13 @@ class PlayerBox(Box): # Clamp position to avoid 32-bit integer overflow max_int32 = 2147483647 # 2^31 - 1 safe_position = min(max_int32, position) if position else 0 - self.seek_bar.set_value(safe_position) + + # Only set value if seek bar has a valid range + if ( + self.seek_bar.get_adjustment() + and self.seek_bar.get_adjustment().get_upper() > 0 + ): + self.seek_bar.set_value(safe_position) except Exception as e: # If any error occurs (widget destroyed, etc), stop the timer @@ -1319,6 +1241,22 @@ class PlayerBox(Box): self._user_seeking = False return False + def _on_seek_bar_realized(self, widget): + """Initialize seek bar when it's realized""" + try: + duration = self.player.length + if duration is None: + duration = 0 + + if duration and duration > 0: + max_int32 = 2147483647 # 2^31 - 1 + safe_duration = min(max_int32, duration) + self.seek_bar.set_range(0, safe_duration) + else: + self.seek_bar.set_range(0, 100) + except Exception as e: + logger.warning(f"Failed to initialize seek bar: {e}") + def _on_scale_value_changed(self, scale: Scale): """Handle seek bar value changes - only when user is seeking""" if self.player and not self.exit and self._user_seeking: diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py index cfa00bc..a212b0d 100644 --- a/modules/controlcenter/main.py +++ b/modules/controlcenter/main.py @@ -1,17 +1,12 @@ import subprocess -from fabric.utils import idle_add -from fabric.utils.helpers import ( - get_relative_path, -) +from fabric.utils import Gdk, GLib, get_relative_path, idle_add, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label from fabric.widgets.scale import Scale -from fabric.widgets.svg import Svg -from gi.repository import Gdk, GLib -from loguru import logger +from fabric.widgets.wayland import WaylandWindow as Window from modules.controlcenter.bluetooth import ( BluetoothConnections, @@ -20,13 +15,12 @@ from modules.controlcenter.bluetooth import ( from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer from modules.controlcenter.nightlight import create_night_light_widget from modules.controlcenter.per_app_volume import PerAppVolumeControl -from modules.controlcenter.player import PlayerBoxStack +from modules.controlcenter.player import PlayerBoxStack, get_shared_mpris_manager from modules.controlcenter.wifi import WifiConnections from services.brightness import Brightness -from services.mpris import MprisPlayerManager from services.network import NetworkClient from utils.roam import audio_service, modus_service -from widgets.wayland import WaylandWindow as Window +from utils.utils import svg_file brightness_service = Brightness.get_initial() @@ -53,10 +47,10 @@ class ModusControlCenter(Window): self.caffeine_mode = False self._caffeine_process = None - # Lazy loading flags - self._music_initialized = False + # Loading flags - music and expanded player are no longer lazy loaded self._per_app_volume_initialized = False - self._expanded_player_initialized = False + self._signals_connected = False # Track if signals are connected + self._resources_initialized = False # Track if resources are initialized # Store references for cleanup - initialize all as None self._signal_connections = [] @@ -65,33 +59,22 @@ class ModusControlCenter(Window): self._expanded_player_widget = None self._mpris_manager = None # Shared MPRIS manager instance - # Initialize network service for WiFi toggle - self.network_service = NetworkClient() + # Initialize network service for WiFi toggle - lazy load + self.network_service = None self.wifi_service = None - # Initialize flight mode and caffeine states - self._check_initial_states() - - # Wait for network service to be ready + # Initialize flight mode and caffeine states - lazy load + self.caffeine_mode = False + self.flight_mode = False + # Add keybinding immediately (this is fast) self.add_keybinding("Escape", self.hide_controlcenter) volume = 100 - wlan = modus_service.sc("wlan-changed", self.wlan_changed) - bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed) - music = modus_service.sc("music-changed", self.audio_changed) + # Get initial values and connect signals - store connection IDs for cleanup + wlan = modus_service.wlan if modus_service.wlan else "No Connection" + bluetooth = modus_service.bluetooth if modus_service.bluetooth else "Off" - self.network_service.connect("wifi-device-added", self.on_network_ready) - # Store signal connections for cleanup - self._signal_connections.extend( - [ - audio_service.connect("changed", self.audio_changed), - audio_service.connect("changed", self.volume_changed), - modus_service.connect("dont-disturb-changed", self.dnd_changed), - ] - ) - - print(wlan) self.wlan_label = Label( label=wlan, name="wifi-widget-label", @@ -124,8 +107,6 @@ class ModusControlCenter(Window): size=30, h_expand=True, ) - self.volume_scale.connect("change-value", self.set_volume) - self.volume_scale.connect("scroll-event", self.on_volume_scroll) current_brightness = brightness_service.screen_brightness brightness_percentage = ( @@ -146,43 +127,29 @@ class ModusControlCenter(Window): # Only connect brightness controls if brightness service is available if brightness_service.max_screen > 0: - 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.connect("screen", self.brightness_changed) - ) + pass else: # Disable brightness scale if no backlight device available self.brightness_scale.set_sensitive(False) - # Create placeholder music widget - lazy load content when needed - self.music_widget = Box( - name="music-widget", - h_align="start", - children=[], # Empty initially - ) + self._mpris_manager = get_shared_mpris_manager() + self.music_widget = PlayerBoxStack(self._mpris_manager, control_center=self) self.has_bluetooth_open = False self.has_wifi_open = False self.has_per_app_volume_open = False self.has_expanded_player_open = False - self.bluetooth_svg = Svg( - name="bluetooth-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" + self.bluetooth_svg = svg_file( + ( + "applets/bluetooth.svg" if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" + else "applets/bluetooth-off.svg" ), size=42, ) - self.wifi_svg = Svg( - name="wifi-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ), + self.wifi_svg = svg_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg", size=42, ) @@ -244,13 +211,8 @@ class ModusControlCenter(Window): ], ) - self.focus_icon = Svg( - name="focus-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ), + self.focus_icon = svg_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg", size=42, ) @@ -287,13 +249,8 @@ class ModusControlCenter(Window): on_clicked=self.set_dont_disturb, ) - self.flight_icon = Svg( - name="flight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ), + self.flight_icon = svg_file( + "applets/flight-on.svg" if self.flight_mode else "applets/flight-off.svg", size=42, ) @@ -318,12 +275,11 @@ class ModusControlCenter(Window): on_clicked=self.toggle_flight_mode, ) - self.caffeine_icon = Svg( - name="caffeine-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" + self.caffeine_icon = svg_file( + ( + "applets/caffeine-on.svg" if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" + else "applets/caffeine-off.svg" ), size=42, ) @@ -350,16 +306,6 @@ class ModusControlCenter(Window): style_classes="title-widget", h_align="center", ), - # Box( - # orientation="vertical", - # v_expand=True, - # v_align="center", - # h_align="center", - # h_expand=True, - # children=[ - # # self.caffeine_status_label, - # ], - # ), ], ), on_clicked=self.toggle_caffeine, @@ -368,7 +314,7 @@ class ModusControlCenter(Window): # Create night light widget self.night_light_widget = create_night_light_widget(self) - # Create main widgets directly without XML + # Create main widgets directly without XML - defer heavy operations self.widgets = Box( orientation="vertical", h_expand=True, @@ -476,12 +422,10 @@ class ModusControlCenter(Window): Button( name="per-app-volume-button", size=(36, 36), - child=Svg( - svg_file=get_relative_path( - "../../config/assets/icons/player/audio-switcher.svg" - ), + child=svg_file( + "player/audio-switcher.svg", name="per-app-volume-icon", - sidze=32, + size=32, ), on_clicked=self.open_per_app_volume, ), @@ -501,18 +445,32 @@ class ModusControlCenter(Window): self.has_bluetooth_open = False self.has_wifi_open = False - # Lazy-loaded widgets - create placeholders + # Create expanded player widgets immediately (no lazy loading) + self._expanded_player_widget = EmbeddedExpandedPlayer(self) + self.expanded_player_widgets = Box( + orientation="vertical", + h_expand=True, + name="control-center-widgets", + children=[ + self._expanded_player_widget, + ], + ) + + # Lazy-loaded widgets - create placeholders for others self.bluetooth_widgets = None self.wifi_widgets = None self.per_app_volume_widgets = None - self.expanded_player_widgets = None # Create main content boxes self.center_box = CenterBox(start_children=[self.widgets]) self.bluetooth_center_box = None self.wifi_center_box = None self.per_app_volume_center_box = None - self.expanded_player_center_box = None + # Create expanded player center box immediately + self.expanded_player_center_box = CenterBox( + start_children=[self.expanded_player_widgets] + ) + self.expanded_player_center_box.set_size_request(300, -1) # Create revealers for crossfade transitions @@ -527,70 +485,83 @@ class ModusControlCenter(Window): self.connect("notify::visible", self._on_visibility_changed) def _on_visibility_changed(self, widget, param): - """Handle visibility changes for memory management""" + """Handle visibility changes for resource management""" if not self.get_visible(): - self._cleanup_when_hidden() + # Just disconnect signals and reset state flags - don't destroy widgets + self._disconnect_signals_when_hidden() + else: + self._initialize_resources() + + def _initialize_resources(self): + """Initialize resources and connect signals when the control center becomes visible.""" + if self._resources_initialized: + return - def _cleanup_when_hidden(self): - """Aggressively clean up resources when widget is hidden to reduce memory usage""" try: - # Clean up music widget content if it exists - if self._music_widget_content: - # Remove from the parent container - current_children = list(self.music_widget.children) - if self._music_widget_content in current_children: - current_children.remove(self._music_widget_content) - self.music_widget.children = current_children + logger.debug("Initializing control center resources...") - # Trigger periodic cleanup before destroying - if hasattr(self._music_widget_content, "_periodic_cleanup"): - self._music_widget_content._periodic_cleanup() + # Initialize network service lazily + if self.network_service is None: + self.network_service = NetworkClient() + # Connect network signal + self.network_service.connect("wifi-device-added", self.on_network_ready) - # Properly destroy the music widget content - try: - self._music_widget_content.destroy() - except Exception as e: - logger.warning(f"Failed to destroy music widget content: {e}") - self._music_widget_content = None + # Check initial states lazily (only when needed) + self._check_initial_states() - # Clean up shared MPRIS manager when hidden to free memory - if self._mpris_manager: - try: - self._mpris_manager.destroy() - except Exception as e: - logger.warning( - f"Failed to destroy MPRIS manager during cleanup: {e}" - ) - self._mpris_manager = None + # Store signal connections for cleanup + self._signal_connections.extend( + [ + audio_service.connect("changed", self.audio_changed), + audio_service.connect("changed", self.volume_changed), + modus_service.connect("wlan-changed", self.wlan_changed), + modus_service.connect("bluetooth-changed", self.bluetooth_changed), + modus_service.connect("dont-disturb-changed", self.dnd_changed), + ] + ) - # Reset initialization flags to force recreation next time - self._music_initialized = False + # Connect brightness controls if brightness service is available + if brightness_service.max_screen > 0: + 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.connect("screen", self.brightness_changed) + ) - # Force garbage collection - import gc + # Connect volume scale signals + self.volume_scale.connect("change-value", self.set_volume) + self.volume_scale.connect("scroll-event", self.on_volume_scroll) - gc.collect() + # Mark signals as connected + self._signals_connected = True + self._resources_initialized = True - logger.debug("Control center aggressive cleanup completed") + logger.debug("Control center resources initialized successfully") except Exception as e: - logger.warning(f"Control center cleanup failed: {e}") + logger.error(f"Failed to initialize control center resources: {e}") + # Reset flags on failure + self._signals_connected = False + self._resources_initialized = False - def _ensure_music_widget(self): - """Lazy load music widget content - reuse MPRIS manager""" - if not self._music_initialized: - # Create shared MPRIS manager if it doesn't exist - if self._mpris_manager is None: - self._mpris_manager = MprisPlayerManager() + 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 + self._signals_connected = False + self._resources_initialized = False - self._music_widget_content = PlayerBoxStack( - self._mpris_manager, control_center=self - ) - # Add to the music widget's children list - current_children = list(self.music_widget.children) - current_children.append(self._music_widget_content) - self.music_widget.children = current_children - self._music_initialized = True + # Reset state flags + self.has_bluetooth_open = False + self.has_wifi_open = False + self.has_per_app_volume_open = False + self.has_expanded_player_open = False + + # Reset current view + self.current_view = "main" + + except Exception as e: + logger.warning(f"Control center signal disconnection failed: {e}") def _ensure_bluetooth_widgets(self): """Lazy load bluetooth widgets""" @@ -599,24 +570,7 @@ class ModusControlCenter(Window): orientation="vertical", h_expand=True, v_expand=True, - children=[ - self.bluetooth_man, - # Box( - # orientation="horizontal", - # name="top-widget", - # h_expand=True, - # children=[ - # Box( - # orientation="vertical", - # name="wb-widget", - # style_classes="menu", - # spacing=5, - # children=[ - # ], - # ), - # ], - # ), - ], + children=[self.bluetooth_man], ) self.bluetooth_center_box = Box( h_expand=True, v_expand=True, children=[self.bluetooth_widgets] @@ -658,33 +612,18 @@ class ModusControlCenter(Window): ) self.per_app_volume_center_box.set_size_request(300, -1) - def _ensure_expanded_player_widgets(self): - """Lazy load expanded player widgets""" - if self.expanded_player_widgets is None: - if self._expanded_player_widget is None: - self._expanded_player_widget = EmbeddedExpandedPlayer(self) - - self.expanded_player_widgets = Box( - orientation="vertical", - h_expand=True, - name="control-center-widgets", - children=[ - self._expanded_player_widget, - ], - ) - self.expanded_player_center_box = CenterBox( - start_children=[self.expanded_player_widgets] - ) - self.expanded_player_center_box.set_size_request(300, -1) - def _check_initial_states(self): - # Check if caffeine is already running + # 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 + ["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 Exception: + except (subprocess.TimeoutExpired, Exception): self.caffeine_mode = False # Flight mode starts as False (normal mode) @@ -699,12 +638,8 @@ class ModusControlCenter(Window): def set_dont_disturb(self, *_): self.focus_mode = not self.focus_mode modus_service.dont_disturb = self.focus_mode - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" ) self.focus_status_label.set_label("On" if self.focus_mode else "Off") @@ -730,12 +665,10 @@ class ModusControlCenter(Window): self.bluetooth_man.client.set_enabled(True) # Update icon - self.flight_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/flight-on.svg" - if self.flight_mode - else "../../config/assets/icons/applets/flight-off.svg" - ) + self.flight_icon.dynamic_file( + "applets/flight-on.svg" + if self.flight_mode + else "applets/flight-off.svg" ) except Exception as e: @@ -744,7 +677,6 @@ class ModusControlCenter(Window): def toggle_caffeine(self, *_): try: if self.caffeine_mode: - # Turn off caffeine inhibit_script = get_relative_path("../../utils/inhibit.py") subprocess.run(["python3", inhibit_script, "off"], check=False) self.caffeine_mode = False @@ -755,19 +687,16 @@ class ModusControlCenter(Window): pass self._caffeine_process = None else: - # Turn on caffeine 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_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/caffeine-on.svg" - if self.caffeine_mode - else "../../config/assets/icons/applets/caffeine-off.svg" - ) + 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") @@ -775,18 +704,22 @@ class ModusControlCenter(Window): logger.warning(f"Failed to toggle caffeine: {e}") def set_volume(self, _, __, volume): + if not self._signals_connected: + return self._updating_volume = True audio_service.speaker.volume = round(volume) self._updating_volume = False def set_brightness(self, _, __, brightness): + 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 self._updating_brightness = False def brightness_changed(self, _, brightness_value): - if self._updating_brightness: + if not self._signals_connected or self._updating_brightness: return if brightness_service.max_screen > 0: @@ -799,6 +732,8 @@ class ModusControlCenter(Window): ) def on_volume_scroll(self, widget, event): + if not self._signals_connected: + return False current_value = self.volume_scale.get_value() scroll_step = 5 if event.direction == Gdk.ScrollDirection.UP: @@ -812,6 +747,8 @@ class ModusControlCenter(Window): return True def on_brightness_scroll(self, widget, event): + if not self._signals_connected: + return False current_value = self.brightness_scale.get_value() scroll_step = 5 if event.direction == Gdk.ScrollDirection.UP: @@ -825,8 +762,9 @@ class ModusControlCenter(Window): return True def toggle_bluetooth(self, *_): + if not self._resources_initialized: + return try: - # Access the bluetooth client from the bluetooth manager if hasattr(self, "bluetooth_man") and hasattr(self.bluetooth_man, "client"): current_state = self.bluetooth_man.client.enabled set_bluetooth_enabled_with_fallback( @@ -838,32 +776,26 @@ class ModusControlCenter(Window): logger.warning(f"Failed to toggle bluetooth: {e}") def on_network_ready(self, *_): - """Called when network service is ready""" self.wifi_service = self.network_service.wifi_device if self.wifi_service: - # Connect to WiFi state changes to update icon self.wifi_service.connect("notify::wireless-enabled", self.update_wifi_icon) def update_wifi_icon(self, *_): - """Update WiFi icon based on current state""" try: if self.wifi_service and hasattr(self, "wifi_svg"): is_enabled = self.wifi_service.wireless_enabled - icon_file = ( - "../../config/assets/icons/applets/wifi.svg" - if is_enabled - else "../../config/assets/icons/applets/wifi-off.svg" + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if is_enabled else "applets/wifi-off.svg" ) - self.wifi_svg.set_from_file(get_relative_path(icon_file)) except Exception as e: logger.warning(f"Failed to update WiFi icon: {e}") def toggle_wifi(self, *_): - """Toggle wifi on/off""" + if not self._resources_initialized: + return try: if self.wifi_service: self.wifi_service.toggle_wifi() - # Update icon immediately after toggle GLib.timeout_add(100, self.update_wifi_icon) else: logger.warning("WiFi device not available for toggling") @@ -874,11 +806,15 @@ class ModusControlCenter(Window): self.children = children def open_bluetooth(self, *_): + if not self._resources_initialized: + return self._ensure_bluetooth_widgets() idle_add(lambda *_: self.set_children(self.bluetooth_center_box)) self.has_bluetooth_open = True def open_wifi(self, *_): + if not self._resources_initialized: + return self._ensure_wifi_widgets() idle_add(lambda *_: self.set_children(self.wifi_center_box)) self.has_wifi_open = True @@ -898,6 +834,8 @@ class ModusControlCenter(Window): self.has_wifi_open = False def open_per_app_volume(self, *_): + if not self._resources_initialized: + return self._ensure_per_app_volume_widgets() if self.current_view == "expanded_player": # If coming from expanded player, use crossfade @@ -923,33 +861,30 @@ class ModusControlCenter(Window): self.has_per_app_volume_open = False def open_expanded_player(self, *_): - self._ensure_expanded_player_widgets() + if not self._resources_initialized: + return self._crossfade_to_view("expanded_player") self.has_expanded_player_open = True - # Refresh the player when opening if self._expanded_player_widget: self._expanded_player_widget.refresh() def close_expanded_player(self, *_): + # Just hide the expanded player widget, don't destroy it self._crossfade_to_view("main") self.has_expanded_player_open = False def _crossfade_to_view(self, view_name): """Handle transitions between views""" if view_name == "expanded_player": - # Show expanded player - self._ensure_expanded_player_widgets() idle_add(lambda *_: self.set_children(self.expanded_player_center_box)) self.current_view = "expanded_player" elif view_name == "main": - # Show main view idle_add(lambda *_: self.set_children(self.center_box)) self.current_view = "main" def _set_mousecapture(self, visible: bool): if visible: - # Lazy load music widget when becoming visible - self._ensure_music_widget() + GLib.idle_add(self._initialize_resources) self.set_visible(visible) if not visible: @@ -962,7 +897,7 @@ class ModusControlCenter(Window): self, _, ): - if self._updating_volume: + if not self._signals_connected or self._updating_volume: return GLib.idle_add( @@ -970,12 +905,10 @@ class ModusControlCenter(Window): ) def wlan_changed(self, _, wlan): - self.wifi_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/wifi.svg" - if wlan != "No Connection" - else "../../config/assets/icons/applets/wifi-off.svg" - ) + if not self._signals_connected: + return + self.wifi_svg.dynamic_file( + "applets/wifi.svg" if wlan != "No Connection" else "applets/wifi-off.svg" ) if wlan != "No Connection": if wlan.startswith("connected:"): @@ -995,12 +928,12 @@ class ModusControlCenter(Window): GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) def bluetooth_changed(self, _, bluetooth): - self.bluetooth_svg.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/bluetooth.svg" - if bluetooth != "disabled" - else "../../config/assets/icons/applets/bluetooth-off.svg" - ) + if not self._signals_connected: + return + self.bluetooth_svg.dynamic_file( + "applets/bluetooth.svg" + if bluetooth != "disabled" + else "applets/bluetooth-off.svg" ) if bluetooth != "disabled": if bluetooth.startswith("connected:"): @@ -1018,61 +951,247 @@ class ModusControlCenter(Window): GLib.idle_add(lambda: self.bluetooth_label.set_label("Off")) def audio_changed(self, *_): + if not self._signals_connected: + return pass def dnd_changed(self, _, dnd_state): + if not self._signals_connected: + return self.focus_mode = dnd_state - self.focus_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/dnd.svg" - if self.focus_mode - else "../../config/assets/icons/applets/dnd-off.svg" - ) + self.focus_icon.dynamic_file( + "applets/dnd.svg" if self.focus_mode else "applets/dnd-off.svg" ) self.focus_status_label.set_label("On" if self.focus_mode else "Off") def _init_mousecapture(self, mousecapture): self._mousecapture_parent = mousecapture + def _disconnect_all_signals(self): + """Disconnect all signal connections to prevent memory leaks""" + try: + for connection in self._signal_connections: + try: + if connection and hasattr(connection, "disconnect"): + connection.disconnect() + except Exception as e: + logger.warning(f"Failed to disconnect signal: {e}") + + self._signal_connections.clear() + + try: + self.disconnect_by_func(self._on_visibility_changed) + except Exception as e: + logger.warning(f"Failed to disconnect visibility signal: {e}") + + try: + self.disconnect_by_func(self.hide_controlcenter) + except Exception as e: + logger.warning(f"Failed to disconnect keybinding: {e}") + + if hasattr(self, "network_service") and self.network_service: + try: + self.network_service.disconnect_by_func(self.on_network_ready) + except Exception as e: + logger.warning(f"Failed to disconnect network signal: {e}") + + if hasattr(self, "brightness_service") and self.brightness_service: + try: + self.brightness_service.disconnect_by_func(self.brightness_changed) + except Exception as e: + logger.warning(f"Failed to disconnect brightness signal: {e}") + + if hasattr(self, "audio_service") and audio_service: + try: + audio_service.disconnect_by_func(self.audio_changed) + audio_service.disconnect_by_func(self.volume_changed) + except Exception as e: + logger.warning(f"Failed to disconnect audio signals: {e}") + + if hasattr(self, "modus_service") and modus_service: + try: + modus_service.disconnect_by_func(self.wlan_changed) + modus_service.disconnect_by_func(self.bluetooth_changed) + modus_service.disconnect_by_func(self.dnd_changed) + except Exception as e: + logger.warning(f"Failed to disconnect modus signals: {e}") + + if hasattr(self, "volume_scale") and 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 as e: + logger.warning(f"Failed to disconnect volume scale signals: {e}") + + if hasattr(self, "brightness_scale") and 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 as e: + logger.warning( + f"Failed to disconnect brightness scale signals: {e}" + ) + + self._signals_connected = False + + logger.debug("All signals disconnected successfully") + + except Exception as e: + logger.warning(f"Signal disconnection failed: {e}") + + def _cleanup_managers(self): + """Clean up all manager instances""" + try: + if hasattr(self, "wifi_man") and self.wifi_man: + try: + self.wifi_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy WiFi manager: {e}") + self.wifi_man = None + + if hasattr(self, "bluetooth_man") and self.bluetooth_man: + try: + self.bluetooth_man.destroy() + except Exception as e: + logger.warning(f"Failed to destroy Bluetooth manager: {e}") + self.bluetooth_man = None + + if hasattr(self, "network_service") and self.network_service: + try: + self.network_service.destroy() + except Exception as e: + logger.warning(f"Failed to destroy network service: {e}") + self.network_service = None + + if hasattr(self, "wifi_service") and self.wifi_service: + try: + self.wifi_service.disconnect_by_func(self.update_wifi_icon) + except Exception as e: + logger.warning(f"Failed to disconnect WiFi service: {e}") + self.wifi_service = None + + self._resources_initialized = False + + logger.debug("All managers cleaned up successfully") + + except Exception as e: + logger.warning(f"Manager cleanup failed: {e}") + + def _cleanup_widgets(self): + try: + # Clean up main widgets + if hasattr(self, "widgets") and self.widgets: + try: + self.widgets.destroy() + except Exception as e: + logger.warning(f"Failed to destroy main widgets: {e}") + self.widgets = None + + # Clean up center box + if hasattr(self, "center_box") and self.center_box: + try: + self.center_box.destroy() + except Exception as e: + logger.warning(f"Failed to destroy center box: {e}") + self.center_box = None + + # Clean up individual widgets + widget_attrs = [ + "wlan_widget", + "bluetooth_widget", + "focus_widget", + "flight_widget", + "caffeine_widget", + "night_light_widget", + "volume_scale", + "brightness_scale", + "wlan_label", + "bluetooth_label", + "focus_status_label", + "caffeine_status_label", + "wlan_svg", + "bluetooth_svg", + "focus_icon", + "flight_icon", + "caffeine_icon", + ] + + for attr in widget_attrs: + if hasattr(self, attr) and getattr(self, attr): + try: + widget = getattr(self, attr) + if hasattr(widget, "destroy"): + widget.destroy() + except Exception as e: + logger.warning(f"Failed to destroy {attr}: {e}") + setattr(self, attr, None) + + logger.debug("All widgets cleaned up successfully") + + except Exception as e: + 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}") + + def _complete_cleanup(self): + """Perform complete cleanup of all resources""" + try: + logger.debug("Starting complete cleanup...") + self._disconnect_all_signals() + self._cleanup_managers() + self._cleanup_widgets() + self._cleanup_processes() + self._disconnect_signals_when_hidden() + + # Force garbage collection + import gc + + gc.collect() + + logger.debug("Complete cleanup finished successfully") + + except Exception as e: + logger.error(f"Complete cleanup failed: {e}") + def hide_controlcenter(self, *_): - self._mousecapture_parent.toggle_mousecapture() - self.set_visible(False) + try: + # Just disconnect signals when hiding, don't destroy widgets + self._disconnect_signals_when_hidden() + + # Hide the control center + if hasattr(self, "_mousecapture_parent"): + self._mousecapture_parent.toggle_mousecapture() + self.set_visible(False) + + except Exception as e: + logger.error(f"Failed to hide control center: {e}") + # Still try to hide even if cleanup fails + self.set_visible(False) def destroy(self): - """Clean up resources when widget is destroyed""" - # Disconnect all signal connections - for connection in self._signal_connections: + try: + self._complete_cleanup() + super().destroy() + logger.debug("Control center destroyed successfully") + except Exception as e: + logger.error(f"Failed to destroy control center: {e}") try: - connection.disconnect() + super().destroy() except: pass - - # Clean up caffeine process - if self._caffeine_process: - try: - self._caffeine_process.terminate() - except: - pass - self._caffeine_process = None - - # Clean up heavy components - if hasattr(self, "wifi_man") and self.wifi_man: - self.wifi_man.destroy() - if hasattr(self, "bluetooth_man") and self.bluetooth_man: - self.bluetooth_man.destroy() - if self._music_widget_content: - self._music_widget_content.destroy() - if self._per_app_volume_widget: - self._per_app_volume_widget.destroy() - if self._expanded_player_widget: - self._expanded_player_widget.destroy() - - # Clean up shared MPRIS manager - if self._mpris_manager: - try: - self._mpris_manager.destroy() - except Exception as e: - logger.warning(f"Failed to destroy MPRIS manager: {e}") - self._mpris_manager = None - - super().destroy() diff --git a/modules/controlcenter/nightlight.py b/modules/controlcenter/nightlight.py index c5414c2..d1ab0d4 100644 --- a/modules/controlcenter/nightlight.py +++ b/modules/controlcenter/nightlight.py @@ -1,15 +1,11 @@ -# Standard library imports import subprocess -# Fabric imports -from fabric.utils.helpers import get_relative_path +from fabric.utils import logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label -from fabric.widgets.svg import Svg -# Local imports -from loguru import logger +from utils.utils import svg_file class NightLightControl: @@ -73,12 +69,11 @@ def create_night_light_widget(control_center): night_light = NightLightControl() # Create icon - night_light_icon = Svg( - name="nightlight-icon", - svg_file=get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" + night_light_icon = svg_file( + ( + "applets/redshift-status-on.svg" if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" + else "applets/redshift-status-off.svg" ), size=42, ) @@ -97,12 +92,10 @@ def create_night_light_widget(control_center): """Toggle night light and update UI""" if night_light.toggle(): # Update icon - night_light_icon.set_from_file( - get_relative_path( - "../../config/assets/icons/applets/redshift-status-on.svg" - if night_light.is_active - else "../../config/assets/icons/applets/redshift-status-off.svg" - ) + night_light_icon.dynamic_file( + "applets/redshift-status-on.svg" + if night_light.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") @@ -143,4 +136,3 @@ def create_night_light_widget(control_center): ) return night_light_widget - diff --git a/modules/controlcenter/per_app_volume.py b/modules/controlcenter/per_app_volume.py index 08ba18a..e9fa338 100644 --- a/modules/controlcenter/per_app_volume.py +++ b/modules/controlcenter/per_app_volume.py @@ -1,13 +1,10 @@ -# Standard library imports -from gi.repository import GLib - -# Fabric imports +from fabric.utils import GLib from fabric.widgets.box import Box from fabric.widgets.button import Button +from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.scale import Scale from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.image import Image from fabric.widgets.separator import Separator # Local imports diff --git a/modules/controlcenter/player.py b/modules/controlcenter/player.py index d5a6fd4..e49a389 100644 --- a/modules/controlcenter/player.py +++ b/modules/controlcenter/player.py @@ -1,10 +1,7 @@ import os import re -import tempfile -import urllib.parse -import urllib.request +import time from typing import List -import threading from fabric.utils import ( bulk_connect, @@ -12,19 +9,47 @@ from fabric.utils import ( from fabric.utils.helpers import get_relative_path from fabric.widgets.box import Box from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.overlay import Overlay from fabric.widgets.stack import Stack -from fabric.widgets.svg import Svg -from gi.repository import GLib, GObject -from fabric.widgets.centerbox import CenterBox +from gi.repository import Gio, GLib, GObject from loguru import logger -from services.mpris import MprisPlayer, MprisPlayerManager import config.data as data +from services.mpris import MprisPlayer, MprisPlayerManager +from utils.utils import svg_file CACHE_DIR = f"{data.CACHE_DIR}/media" +MEDIA_CACHE = CACHE_DIR +if not os.path.exists(CACHE_DIR): + os.makedirs(CACHE_DIR) +if not os.path.exists(MEDIA_CACHE): + os.makedirs(MEDIA_CACHE) + +# Global shared MPRIS manager +_shared_mpris_manager = None + + +def get_shared_mpris_manager(): + """Get shared MPRIS manager instance to reduce memory usage.""" + global _shared_mpris_manager + if _shared_mpris_manager is None: + # Create MPRIS manager immediately but don't wait for DBus discovery + _shared_mpris_manager = MprisPlayerManager() + return _shared_mpris_manager + + +def cleanup_shared_mpris_manager(): + """Clean up the shared MPRIS manager to free memory.""" + global _shared_mpris_manager + if _shared_mpris_manager is not None: + try: + _shared_mpris_manager.destroy() + except Exception as e: + logger.warning(f"Failed to destroy shared MPRIS manager: {e}") + _shared_mpris_manager = None def cleanup_old_cache_files(): @@ -33,8 +58,6 @@ def cleanup_old_cache_files(): if not os.path.exists(CACHE_DIR): return - import time - current_time = time.time() one_day_ago = current_time - (24 * 60 * 60) # 24 hours cache_files = [] @@ -85,10 +108,10 @@ class PlayerBoxStack(Box): """A widget that displays the current player information.""" def __init__( - self, mpris_manager: MprisPlayerManager, control_center=None, **kwargs + self, mpris_manager: MprisPlayerManager = None, control_center=None, **kwargs ): - # Clean up old cache files on startup - cleanup_old_cache_files() + # Defer cache cleanup to avoid blocking startup + GLib.idle_add(cleanup_old_cache_files) # The player stack self.player_stack = Stack( @@ -114,25 +137,96 @@ class PlayerBoxStack(Box): self.player_stack.children = [self.no_media_box] self.set_visible(True) + # Use shared MPRIS manager if none provided self.mpris_manager = mpris_manager - # Track connections for cleanup - store (object, handler_id) tuples - connections = bulk_connect( - self.mpris_manager, - { - "player-appeared": self.on_new_player, - "player-vanished": self.on_lost_player, - }, - ) - # Store as (object, handler_id) tuples - for handler_id in connections: - self._signal_connections.append((self.mpris_manager, handler_id)) + # Initialize MPRIS manager if provided, otherwise defer + if self.mpris_manager is not None: + self._init_mpris_manager() + else: + # Will be initialized later via _init_mpris_manager() + pass - for player in self.mpris_manager.players: # type: ignore - logger.info( - f"[PLAYER MANAGER] player found: {player.get_property('player-name')}", + # Connect to visibility changes for cleanup + self.connect("notify::visible", self._on_visibility_changed) + + def _on_visibility_changed(self, widget, param): + """Monitor visibility changes to debug hiding issues.""" + is_visible = self.get_visible() + logger.debug(f"[PlayerBoxStack] Visibility changed to: {is_visible}") + + if not is_visible: + # Check if we should be visible + current_children = self.player_stack.get_children() + if len(current_children) > 0: + logger.warning( + f"[PlayerBoxStack] Widget was hidden but has {len(current_children)} children - forcing visibility" + ) + GLib.idle_add(self.set_visible, True) + + def set_visible(self, visible: bool) -> None: + """Override set_visible to ensure the widget is never hidden when it should show content.""" + # Always ensure the widget is visible if we have content to show + if not visible: + current_children = self.player_stack.get_children() + if len(current_children) > 0: + # Don't allow hiding if we have content + logger.debug( + "[PlayerBoxStack] Preventing widget from being hidden - has content" + ) + return + + super().set_visible(visible) + + def get_visible(self) -> bool: + """Override get_visible to ensure the widget always reports as visible when it has content.""" + # Check if we have content to show + current_children = self.player_stack.get_children() + if len(current_children) > 0: + # If we have content, we should always be visible + return True + + # Otherwise, use the parent's visibility state + return super().get_visible() + + def _init_mpris_manager(self): + """Initialize MPRIS manager and connect signals""" + if self.mpris_manager is None: + return + + try: + # Track connections for cleanup - store (object, handler_id) tuples + connections = bulk_connect( + self.mpris_manager, + { + "player-appeared": self.on_new_player, + "player-vanished": self.on_lost_player, + }, ) - self.on_new_player(self.mpris_manager, player) + # Store as (object, handler_id) tuples + for handler_id in connections: + self._signal_connections.append((self.mpris_manager, handler_id)) + + # Process existing players asynchronously to avoid blocking + def process_existing_players(): + try: + for player in self.mpris_manager.players.values(): # type: ignore + self.on_new_player(self.mpris_manager, player) + logger.debug("PlayerBoxStack initialized successfully") + except Exception as e: + logger.error(f"Failed to process existing players: {e}") + + # Use idle_add to process players asynchronously + GLib.idle_add(process_existing_players) + + # Also check the playing state after a short delay to ensure consistency + GLib.timeout_add(1000, self._check_and_update_playing_state) + + # Ensure the widget is always visible + GLib.timeout_add(500, self._ensure_visibility) + + except Exception as e: + logger.error(f"Failed to initialize PlayerBoxStack signals: {e}") def destroy(self): """Clean up resources when the widget is destroyed.""" @@ -160,6 +254,14 @@ class PlayerBoxStack(Box): except Exception: pass + # Clean up shared MPRIS manager if this is the last instance + if ( + hasattr(self, "mpris_manager") + and self.mpris_manager == _shared_mpris_manager + ): + # Only cleanup if no other widgets are using it + cleanup_shared_mpris_manager() + super().destroy() def _periodic_cleanup(self): @@ -191,18 +293,12 @@ class PlayerBoxStack(Box): # Clean up old cache files more aggressively cleanup_old_cache_files() - # Force garbage collection - import gc - - gc.collect() - - logger.debug("PlayerBoxStack enhanced cleanup completed") except Exception as e: logger.warning(f"PlayerBoxStack enhanced cleanup failed: {e}") def _create_no_media_box(self): """Create a placeholder box for when no media is playing.""" - fallback_cover_path = f"{data.HOME_DIR}/.current.wall" + fallback_cover_path = get_relative_path("../../assets/icons/music.svg") # Album cover with fallback image album_cover = Box(style_classes="album-image-c") @@ -305,7 +401,7 @@ class PlayerBoxStack(Box): for i, player_box in enumerate(players): if ( hasattr(player_box, "player") - and player_box.player.playback_status == "playing" + and str(player_box.player.playback_status).lower() == "playing" ): return i return None @@ -314,28 +410,104 @@ class PlayerBoxStack(Box): """Switch to the currently playing player if one exists.""" playing_index = self._find_playing_player_index() if playing_index is not None and playing_index != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Auto-switching to playing player at index { - playing_index - }" - ) self.on_player_clicked_by_index(playing_index) + def _check_and_update_playing_state(self): + """Check if any players are playing and update the display accordingly.""" + players: List[PlayerBox] = self.player_stack.get_children() + has_playing_player = False + + # Check if any player is currently playing + for player_box in players: + if ( + hasattr(player_box, "player") + and str(player_box.player.playback_status).lower() == "playing" + ): + has_playing_player = True + break + + # If no players are playing, show the no media box + if not has_playing_player: + # Check if we already have the no media box visible + current_children = self.player_stack.get_children() + if len(current_children) == 1 and current_children[0] == self.no_media_box: + # Already showing no media, no need to change + return + + # Hide all player boxes and show no media + for player_box in players: + if hasattr(player_box, "destroy"): + try: + player_box.destroy() + except Exception as e: + logger.warning(f"Failed to destroy player box: {e}") + + # Clear player buttons + self.player_buttons.clear() + + # Show the no media box + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + + # Ensure the widget is visible + self.set_visible(True) + self.player_stack.set_visible_child(self.no_media_box) + + logger.debug( + "[PlayerBoxStack] No players playing, showing 'No media playing'" + ) + + def _ensure_visibility(self): + """Ensure the widget is always visible and showing appropriate content.""" + try: + # Always ensure the widget is visible + self.set_visible(True) + + # Check current state + current_children = self.player_stack.get_children() + + # If no children, show no media box + if len(current_children) == 0: + self.player_stack.children = [self.no_media_box] + self.current_stack_pos = 0 + logger.debug("[PlayerBoxStack] No children found, showing no media box") + + # If only no_media_box is visible, ensure it's the active child + elif ( + len(current_children) == 1 and current_children[0] == self.no_media_box + ): + self.player_stack.set_visible_child(self.no_media_box) + logger.debug("[PlayerBoxStack] Ensuring no media box is visible") + + # Force the widget to be visible and show content + self.set_visible(True) + self.player_stack.set_visible(True) + + # If we have the no media box, make sure it's visible + if self.no_media_box in current_children: + self.no_media_box.set_visible(True) + + logger.debug( + f"[PlayerBoxStack] Current state: {len(current_children)} children, visible: {self.get_visible()}, stack_visible: {self.player_stack.get_visible()}" + ) + + except Exception as e: + logger.error(f"[PlayerBoxStack] Error in _ensure_visibility: {e}") + def on_player_playback_changed(self, player_box, status): """Called when a player's playback status changes.""" + status = str(status).lower() if isinstance(status, str) else status if status == "playing": # Find this player's index and switch to it players: List[PlayerBox] = self.player_stack.get_children() for i, pb in enumerate(players): if pb == player_box: if i != self.current_stack_pos: - logger.info( - f"[PlayerBoxStack] Switching to playing player: { - player_box.player.player_name - }" - ) self.on_player_clicked_by_index(i) break + elif status in ["paused", "stopped"]: + # Check if any other players are playing + self._check_and_update_playing_state() def on_player_clicked(self, type): # unset active from prev active button @@ -357,11 +529,6 @@ class PlayerBoxStack(Box): # set new active button if self.player_buttons and self.current_stack_pos < len(self.player_buttons): - print( - f"[PlayerBoxStack] Switching to player at index { - self.current_stack_pos - }" - ) self.player_buttons[self.current_stack_pos].add_style_class("active") self.player_stack.set_visible_child( self.player_stack.get_children()[self.current_stack_pos], @@ -394,6 +561,11 @@ class PlayerBoxStack(Box): # if player_name in self.config.get("ignore", []): # return + # Show all players, but we'll handle their playback status in the UI + # if player.playback_status != "playing": + # logger.debug(f"[PlayerBoxStack] Player {player_name} is not playing (status: {player.playback_status}), skipping display") + # return + # Remove the no media box if it's the only child if ( len(self.player_stack.get_children()) == 1 @@ -405,7 +577,7 @@ class PlayerBoxStack(Box): self.set_visible(True) new_player_box = PlayerBox( - player=MprisPlayer(player), + player=player, player_stack=self, control_center=self.control_center, ) @@ -415,9 +587,6 @@ class PlayerBoxStack(Box): ] self.make_new_player_button(self.player_stack.get_children()[-1]) - logger.info( - f"[PLAYER MANAGER] adding new player: {player.get_property('player-name')}", - ) if self.player_buttons and self.current_stack_pos < len(self.player_buttons): self.player_buttons[self.current_stack_pos].set_style_classes(["active"]) @@ -427,9 +596,8 @@ class PlayerBoxStack(Box): # Check if this new player is playing and switch to it self._switch_to_playing_player() - def on_lost_player(self, mpris_manager, player_name): + def on_lost_player(self, mpris_manager, bus_name): # the playerBox is automatically removed from mprisbox children on being removed - logger.info(f"[PLAYER_MANAGER] Player Removed {player_name}") players: List[PlayerBox] = self.player_stack.get_children() # Find and properly destroy the player box @@ -437,7 +605,7 @@ class PlayerBoxStack(Box): for player_box in players: if ( hasattr(player_box, "player") - and player_box.player.player_name == player_name + and getattr(player_box.player, "bus_name", None) == bus_name ): player_box_to_remove = player_box break @@ -510,11 +678,7 @@ class PlayerBoxStack(Box): def _update_all_player_buttons(self): """Update all player boxes with the current button state""" players: List[PlayerBox] = self.player_stack.get_children() - logger.info( - f"[PlayerBoxStack] Updating buttons for {len(players)} players, { - len(self.player_buttons) - } buttons" - ) + for player_box in players: if hasattr(player_box, "update_buttons"): player_box.update_buttons(self.player_buttons, len(players) > 1) @@ -540,7 +704,7 @@ class PlayerBox(Box): self.player: MprisPlayer = player self.player_stack = player_stack self.control_center = control_center - self.fallback_cover_path = f"{data.HOME_DIR}/.current.wall" + self.cover_path = get_relative_path("../../assets/icons/music.svg") # Add controls_box attribute early for compatibility # Temporary placeholder @@ -552,24 +716,25 @@ class PlayerBox(Box): # State self.exit = False self.skipped = False + self._signal_connections = [] # Track signal connections # Memory management self.temp_artwork_files = [] # Track temp files for cleanup - self.current_download_thread = None # Track current download thread self._download_cancelled = False # Flag to cancel downloads - self._signal_connections = [] # Track signal connections + # Album art with existing Box widget self.album_cover = Box(style_classes="album-image-c") - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) + self.album_cover.set_style(f"background-image:url('{self.cover_path}')") self.image_stack = Box( h_align="start", v_align="center", name="player-image-stack", ) - self.image_stack.children = [*self.image_stack.children, self.album_cover] + self.image_stack.children = [self.album_cover] + + # Connect to arturl changes + self.player.connect("notify::arturl", self.set_image) self.app_icon = Box( children=Image( @@ -585,6 +750,7 @@ class PlayerBox(Box): self.app_icon, ], ) + # Track Info self.track_title = Label( @@ -620,9 +786,7 @@ class PlayerBox(Box): self.track_artist, "label", GObject.BindingFlags.DEFAULT, - lambda _, x: ( - re.sub(r"\r?\n", " ", x) if x != "" and x is not None else "No Artist" - ), # type: ignore + lambda _, x: (", ".join(x) if x and isinstance(x, list) else "No Artist"), # type: ignore ) self.track_info = Box( @@ -645,16 +809,8 @@ class PlayerBox(Box): ) # Create SVG icons with consistent sizing - self.skip_next_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/fwd.svg"), - ) - self.play_pause_icon = Svg( - name="control-buttons", - size=(22, 22), - svg_file=get_relative_path("../../config/assets/icons/player/Pause.svg"), - ) + self.skip_next_icon = svg_file("player/fwd.svg", size=22) + self.play_pause_icon = svg_file("player/Pause.svg", size=22) # Fixed size buttons to prevent layout shifts self.play_pause_button = Button( @@ -709,15 +865,9 @@ class PlayerBox(Box): spacing=5, name="outer-player-box-c", h_expand=True, - # style="background-color:#fff", on_clicked=self._on_outer_box_clicked, h_align="start", - # children=[ child=self.inner_box, - # self.inner_box, - # self.player_info_box, - # self.image, - # ], ) self.box = CenterBox( @@ -726,12 +876,12 @@ class PlayerBox(Box): h_align="center", start_children=[ self.outer_box, - # self.stack_buttons_box, ], end_children=[ self.button_box, ], ) + self.box.set_size_request(352, -1) self.children = [ *self.children, @@ -742,7 +892,7 @@ class PlayerBox(Box): connections = bulk_connect( self.player, { - "exit": self._on_player_exit, + "closed": self._on_player_exit, "notify::playback-status": self._on_playback_change, "notify::metadata": self._on_metadata, }, @@ -797,16 +947,13 @@ class PlayerBox(Box): self.control_center.open_expanded_player() except Exception as e: logger.warning(f"Failed to handle outer box click: {e}") - import traceback - - logger.error(f"Full traceback: {traceback.format_exc()}") def update_buttons(self, player_buttons, show_buttons): # """Update the stack switcher buttons in this player box""" pass def _on_metadata(self, *_): - self._set_image() + self.set_image() def _cleanup_temp_files(self): """Clean up temporary artwork files.""" @@ -831,124 +978,65 @@ class PlayerBox(Box): def _on_playback_change(self, player, status): status = player.get_property("playback-status") + status_l = str(status).lower() if isinstance(status, str) else status - if status == "paused": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/play.svg") - ) + if status_l == "paused": + self.play_pause_icon.dynamic_file("player/play.svg") - if status == "playing": - self.play_pause_icon.set_from_file( - get_relative_path("../../config/assets/icons/player/Pause.svg") - ) - # Notify the player stack that this player started playing - if self.player_stack and hasattr( - self.player_stack, "on_player_playback_changed" - ): - self.player_stack.on_player_playback_changed(self, status) + if status_l == "playing": + self.play_pause_icon.dynamic_file("player/Pause.svg") - def _update_image(self, image_path): - if image_path and os.path.isfile(image_path): - self.album_cover.set_style(f"background-image:url('{image_path}')") - else: - self.album_cover.set_style( - f"background-image:url('{self.fallback_cover_path}')" - ) - - def _set_image(self, *_): - art_url = self.player.arturl - - parsed = urllib.parse.urlparse(art_url) - if parsed.scheme == "file": - local_arturl = urllib.parse.unquote(parsed.path) - self._update_image(local_arturl) - elif parsed.scheme in ("http", "https"): - # Cancel any existing download to prevent memory buildup - self._download_cancelled = True - - # Use threading.Thread instead of GLib.Thread for better control - if self.current_download_thread and self.current_download_thread.is_alive(): - # Thread will check _download_cancelled flag and exit early - pass - - self._download_cancelled = False - self.current_download_thread = threading.Thread( - target=self._download_and_set_artwork, - args=(art_url,), - daemon=True, # Dies with main thread - ) - self.current_download_thread.start() - else: - self._update_image(art_url) - - def _download_and_set_artwork(self, arturl): - """ - Download the artwork from the given URL asynchronously and update the cover - using GLib.idle_add to ensure UI updates occur on the main thread. - """ - local_arturl = self.fallback_cover_path - temp_file_path = None + # Always notify the player stack about playback status changes + if self.player_stack and hasattr( + self.player_stack, "on_player_playback_changed" + ): + self.player_stack.on_player_playback_changed(self, status_l) + def img_callback(self, source: Gio.File, result: Gio.AsyncResult): try: - # Check if download was cancelled - if self._download_cancelled: - return + if os.path.isfile(self.cover_path): + self.update_image() + except ValueError: + logger.error("[PLAYER] Failed to grab artUrl") - # Clean up old temp files first (keep only last 1 to reduce memory) - if len(self.temp_artwork_files) > 1: - old_files = self.temp_artwork_files[:-1] - for old_file in old_files: - try: - if os.path.exists(old_file): - os.unlink(old_file) - except Exception: - pass - self.temp_artwork_files = self.temp_artwork_files[-1:] + def update_image(self): + self.album_cover.set_style(f"background-image:url('{self.cover_path}')") - # Check again if cancelled - if self._download_cancelled: - return + def set_image(self, *args): + url = self.player.arturl - # Download artwork - parsed = urllib.parse.urlparse(arturl) - suffix = os.path.splitext(parsed.path)[1] or ".png" - - with urllib.request.urlopen(arturl, timeout=10) as response: # Add timeout - if self._download_cancelled: - return - data = response.read() - - # Check one more time if cancelled - if self._download_cancelled: - return - - # Create temp file in cache directory instead of system temp - os.makedirs(CACHE_DIR, exist_ok=True) - with tempfile.NamedTemporaryFile( - delete=False, suffix=suffix, dir=CACHE_DIR - ) as temp_file: - temp_file.write(data) - temp_file_path = temp_file.name - local_arturl = temp_file_path - - # Track temp file for cleanup - if temp_file_path and not self._download_cancelled: - self.temp_artwork_files.append(temp_file_path) - - except Exception as e: - if not self._download_cancelled: - logger.warning(f"Failed to download artwork from {arturl}: {e}") - # Clean up failed temp file - if temp_file_path and os.path.exists(temp_file_path): - try: - os.unlink(temp_file_path) - except Exception: - pass + if url is None or url == "": return - # Only update UI if not cancelled - if not self._download_cancelled: - GLib.idle_add(self._update_image, local_arturl) + new_cover_path = ( + ( + MEDIA_CACHE + + "/" + # type: ignore + + GLib.compute_checksum_for_string(GLib.ChecksumType.SHA1, url, -1) + ) + if "file://" != url[0:7] + else url[7:] + ) + + if new_cover_path == self.cover_path: + self.update_image() + return + + self.cover_path = new_cover_path + + if os.path.exists(self.cover_path): + self.update_image() + return + + Gio.File.new_for_uri(uri=url).copy_async( + Gio.File.new_for_path(self.cover_path), + Gio.FileCopyFlags.OVERWRITE, + GLib.PRIORITY_DEFAULT, + None, + None, + self.img_callback, + ) def close_bluetooth(self, *args): """Placeholder method for compatibility""" diff --git a/modules/controlcenter/wifi.py b/modules/controlcenter/wifi.py index 6cb612d..f48d68c 100644 --- a/modules/controlcenter/wifi.py +++ b/modules/controlcenter/wifi.py @@ -1,22 +1,19 @@ -from widgets.wifi_password_dialog import WiFiPasswordDialog -from services.network import NetworkClient -from fabric.widgets.scrolledwindow import ScrolledWindow -from fabric.widgets.revealer import Revealer -from fabric.widgets.label import Label -from fabric.widgets.image import Image -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.button import Button -from fabric.widgets.box import Box -from fabric.widgets.svg import Svg -from fabric.utils import get_relative_path -from gi.repository import Gdk, GLib, Gtk -from fabric.widgets.separator import Separator -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon -import gi import subprocess -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") +from fabric.utils import Gdk, GLib, Gtk +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.revealer import Revealer +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.separator import Separator + +from services.network import NetworkClient +from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength +from utils.utils import svg_file +from widgets.wifi_password_dialog import WiFiPasswordDialog class WifiNetworkSlot(Box): @@ -42,11 +39,7 @@ class WifiNetworkSlot(Box): # Create connection status indicator using dynamic WiFi icon based on signal strength wifi_icon_path = get_wifi_icon_for_strength(self.strength) - self.dimage = Svg( - svg_file=wifi_icon_path, - size=28, - name="device-icon", - ) + self.dimage = svg_file(wifi_icon_path, size=28) self.wifi_icon_box = Box( children=[self.dimage], style_classes=["wifi-icon-box"], diff --git a/modules/notification/notification.py b/modules/notification/notification.py index 4794c68..2e4e25e 100644 --- a/modules/notification/notification.py +++ b/modules/notification/notification.py @@ -1,5 +1,4 @@ import hashlib -import os import time from fabric.notifications import ( @@ -7,102 +6,69 @@ from fabric.notifications import ( NotificationAction, NotificationCloseReason, ) -from fabric.utils import get_relative_path +from fabric.utils import Gdk, GdkPixbuf, GLib, logger, os from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox from fabric.widgets.image import Image from fabric.widgets.label import Label -from gi.repository import Gdk, GdkPixbuf, GLib # type: ignore -from loguru import logger +from fabric.widgets.wayland import WaylandWindow as Window import config.data as data -from services.modus import notification_service -from utils.functions import escape_markup_text -from utils.roam import modus_service -from widgets.custom_image import CustomImage -from widgets.customrevealer import SlideRevealer -from widgets.wayland import WaylandWindow as Window - -from .unified_cache import ( +from modules.notification.unified_cache import ( + UNIFIED_NOTIFICATION_CACHE_DIR as _SHARED_NOTIFICATION_CACHE_DIR, +) +from modules.notification.unified_cache import ( + cleanup_cache as _unified_cleanup_cache, +) +from modules.notification.unified_cache import ( + cleanup_old_cache_files as _unified_cleanup_old_cache_files, +) +from modules.notification.unified_cache import ( + ensure_cache_dir as _ensure_unified_cache_dir, +) +from modules.notification.unified_cache import ( + get_fallback_icon as _shared_fallback_icon, +) +from modules.notification.unified_cache import ( + get_from_cache as _unified_get_from_cache, +) +from modules.notification.unified_cache import ( get_unified_cache_key, ) +from modules.notification.unified_cache import ( + save_to_cache as _unified_save_to_cache, +) +from services.config import get_config, on_config_change +from services.modus import notification_service +from utils.functions import escape_markup_text, parse_timeout_string +from utils.roam import modus_service +from widgets.clipping_box import ClippingBox +from widgets.custom_image import CustomImage +from widgets.customrevealer import SlideRevealer NOTIFICATION_WIDTH = 360 NOTIFICATION_IMAGE_SIZE = 48 -NOTIFICATION_WIDTH = 360 -NOTIFICATION_IMAGE_SIZE = 48 - -# Unified notification cache directory (for both app icons and notification images) -UNIFIED_NOTIFICATION_CACHE_DIR = os.path.join(data.CACHE_DIR, "notifications") - -# Backward compatibility constants -NOTIFICATION_ICON_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR -NOTIFICATION_IMAGE_CACHE_DIR = UNIFIED_NOTIFICATION_CACHE_DIR +# Use shared unified cache directory from unified_cache +NOTIFICATION_ICON_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR +NOTIFICATION_IMAGE_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR def ensure_notification_cache_dirs(): """Ensure unified notification cache directory exists""" - os.makedirs(UNIFIED_NOTIFICATION_CACHE_DIR, exist_ok=True) + _ensure_unified_cache_dir() def cleanup_old_cache_files(): """Clean up old notification cache files (older than 7 days)""" try: - if not os.path.exists(UNIFIED_NOTIFICATION_CACHE_DIR): - return - - current_time = time.time() - week_ago = current_time - (7 * 24 * 60 * 60) # 7 days - - for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): - filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) - try: - if os.path.isfile(filepath): - file_mtime = os.path.getmtime(filepath) - if file_mtime < week_ago: - os.unlink(filepath) - logger.debug(f"Cleaned up old notification cache: {filename}") - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _unified_cleanup_old_cache_files() except Exception as e: logger.warning(f"Failed to cleanup notification cache: {e}") -def get_unified_cache_key(source_data, size=None, app_name=None): - """Generate a unified cache key that works for both app icons and notification images""" - try: - if hasattr(source_data, "get_pixels"): - # For pixbuf data - use hash of pixel data for deterministic caching - try: - pixel_data = source_data.get_pixels() - image_hash = hashlib.md5(pixel_data).hexdigest()[:8] - return image_hash - except Exception: - # Fallback to timestamp if pixel data fails - return str(int(time.time()))[:8] - elif isinstance(source_data, str): - # For file paths - create hash-based name - if source_data.startswith("file://"): - source_data = source_data[7:] - - # Create hash from file path and size - hash_input = source_data - if size: - hash_input += f"_{size[0]}x{size[1]}" - - return hashlib.md5(hash_input.encode()).hexdigest()[:8] - else: - # Fallback to timestamp - return str(int(time.time()))[:8] - except Exception: - # Ultimate fallback - return str(int(time.time()))[:8] - - -# Backward compatibility get_cache_key = get_unified_cache_key @@ -110,15 +76,11 @@ def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir): """Save a pixbuf to the specified cache directory""" try: ensure_notification_cache_dirs() - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - - # Don't overwrite existing cache - if os.path.exists(cache_path): - return cache_path - - pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification icon: {cache_key}") - return cache_path + # Delegate to unified cache; ignore returned key for compatibility + result = _unified_save_to_cache(pixbuf, cache_key) + if result and result[0]: + return result[0] + return None except Exception as e: logger.warning(f"Failed to cache notification icon: {e}") return None @@ -130,13 +92,9 @@ def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None): cache_dir = NOTIFICATION_ICON_CACHE_DIR try: - cache_path = os.path.join(cache_dir, f"{cache_key}.png") - if os.path.exists(cache_path): - logger.debug(f"Using cached notification icon: {cache_key}") - logger.debug(f"Using cached notification icon: {cache_key}") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - cache_path, fallback_size[0], fallback_size[1], True - ) + pixbuf = _unified_get_from_cache(cache_key, fallback_size) + if pixbuf: + return pixbuf except Exception as e: logger.warning(f"Failed to load cached notification icon: {e}") return None @@ -215,26 +173,11 @@ def load_and_cache_local_icon(file_path, cache_key, size): def load_and_cache_theme_icon(icon_name, cache_key, size): """Load an icon from the current theme and cache it""" # For simplicity, just use fallback since theme icon loading is complex in GTK4 - logger.debug(f"Using fallback for theme icon {icon_name}") return get_fallback_notification_icon(size) def get_fallback_notification_icon(size=(48, 48)): - """Get the fallback notification icon""" - try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") - return GdkPixbuf.Pixbuf.new_from_file_at_scale( - fallback_path, size[0], size[1], True - ) - except Exception as e: - logger.warning(f"Failed to load fallback notification icon: {e}") - # Create a simple colored rectangle as ultimate fallback - try: - return GdkPixbuf.Pixbuf.new( - GdkPixbuf.Colorspace.RGB, True, 8, size[0], size[1] - ) - except: - return None + return _shared_fallback_icon(size) def get_notification_image_cache_key(notification_id, image_pixbuf): @@ -272,21 +215,12 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): # Generate deterministic cache key based on image content cache_key = get_notification_image_cache_key(notification_id, image_pixbuf) - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - - # Check if already cached to avoid redundant work - if os.path.exists(cache_path): - logger.debug(f"Image cache hit - already exists: {cache_key}") - return cache_path, cache_key - - # Try to scale and save the image try: - scaled_pixbuf = image_pixbuf.scale_simple( - size[0], size[1], GdkPixbuf.InterpType.BILINEAR - ) - scaled_pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Generated and cached notification image: {cache_key}") - return cache_path, cache_key + # Let unified cache handle scaling and saving + saved_path, _ = _unified_save_to_cache(image_pixbuf, cache_key, size) + if saved_path: + return saved_path, cache_key + return None, None except Exception as scale_error: logger.debug( f"Failed to cache image (temp file likely gone): {scale_error}" @@ -301,9 +235,7 @@ def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): def get_cached_notification_image(cache_key): """Get a cached notification image or return None if not found""" try: - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - return GdkPixbuf.Pixbuf.new_from_file(cache_path) + return _unified_get_from_cache(cache_key) except Exception as e: logger.warning(f"Failed to load cached notification image: {e}") return None @@ -313,25 +245,7 @@ def cleanup_notification_image_cache(cache_key=None): """Clean up notification image cache - specific key or all""" try: ensure_notification_cache_dirs() - - if cache_key: - # Remove specific cached image - cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - if os.path.exists(cache_path): - os.unlink(cache_path) - logger.debug(f"Cleaned up cached notification image: {cache_key}") - else: - # Remove all cached images - for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug( - f"Cleaned up cached notification image: {filename}" - ) - except Exception as e: - logger.warning(f"Failed to cleanup cache file {filename}: {e}") + _unified_cleanup_cache(cache_key) except Exception as e: logger.warning(f"Failed to cleanup notification image cache: {e}") @@ -343,18 +257,13 @@ def cleanup_notification_specific_caches( try: # Clean up notification image cache if notification_image_cache_key: - cleanup_notification_image_cache(notification_image_cache_key) + _unified_cleanup_cache(notification_image_cache_key) # Clean up app icon cache for this specific source (only 35x35 version) if app_icon_source: # Only clean 35x35 version since we only cache this size now cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35)) - cache_path_35 = os.path.join( - NOTIFICATION_ICON_CACHE_DIR, f"{cache_key_35}.png" - ) - if os.path.exists(cache_path_35): - os.unlink(cache_path_35) - logger.debug(f"Cleaned up cached app icon (35x35): {cache_key_35}") + _unified_cleanup_cache(cache_key_35) except Exception as e: logger.warning(f"Failed to cleanup notification specific caches: {e}") @@ -363,22 +272,7 @@ def cleanup_notification_specific_caches( def cleanup_all_notification_caches(): """Clean up ALL notification caches (icons and images)""" try: - # Clean icon cache - if os.path.exists(NOTIFICATION_ICON_CACHE_DIR): - for filename in os.listdir(NOTIFICATION_ICON_CACHE_DIR): - if filename.endswith(".png"): - filepath = os.path.join(NOTIFICATION_ICON_CACHE_DIR, filename) - try: - os.unlink(filepath) - logger.debug(f"Cleaned up cached notification icon: {filename}") - except Exception as e: - logger.warning( - f"Failed to cleanup icon cache file {filename}: {e}" - ) - - # Clean image cache - cleanup_notification_image_cache() - logger.info("Cleaned up all notification caches") + _unified_cleanup_cache() except Exception as e: logger.warning(f"Failed to cleanup all notification caches: {e}") @@ -401,19 +295,11 @@ def verify_cache_persistence(): if f.endswith(".png") ] - logger.info( - f"Cache persistence check: {len(icon_cache_files)} icons, { - len(image_cache_files) - } images cached" - ) - # Test loading a few cached items to verify they work for cache_file in icon_cache_files[:2]: # Test first 2 icon files try: cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file) test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached icon: {cache_file}") except Exception as e: logger.warning(f"Failed to load cached icon {cache_file}: {e}") @@ -421,8 +307,6 @@ def verify_cache_persistence(): try: cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file) test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached image: {cache_file}") except Exception as e: logger.warning(f"Failed to load cached image {cache_file}: {e}") @@ -453,11 +337,7 @@ def migrate_persistent_notifications(): if hasattr(notification, "app_icon") and notification.app_icon: cache_notification_icon(notification.app_icon, (35, 35)) migrated_count += 1 - logger.debug( - f"Migrated notification for { - notification.app_name - } to use cached app icon" - ) + except Exception as cache_error: logger.debug( f"Failed to cache app icon for {notification.app_name}: { @@ -465,11 +345,6 @@ def migrate_persistent_notifications(): }" ) - if migrated_count > 0: - logger.info( - f"Migrated {migrated_count} persistent notifications to use cached assets" - ) - except Exception as e: logger.warning(f"Failed to migrate persistent notifications: {e}") @@ -554,11 +429,15 @@ class NotificationWidget(Box): def __init__( self, notification: Notification, - timeout_ms=data.NOTIFICATION_TIMEOUT, + timeout_ms=None, show_close_button=True, name="notification", **kwargs, ): + # Get current timeout from config manager if not provided + if timeout_ms is None: + timeout_ms = self._get_current_notification_timeout() + self.show_close_button = show_close_button self.close_button = None self._is_hovered = False @@ -603,22 +482,28 @@ class NotificationWidget(Box): header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple( 24, 24, GdkPixbuf.InterpType.BILINEAR ) - app_icon = CustomImage(pixbuf=header_icon_pixbuf) - app_icon.set_name("notification-icon") + app_icon = ClippingBox( + name="notification-icon", + children=Image(pixbuf=header_icon_pixbuf), + ) else: # Fallback to theme icon if caching fails completely - app_icon = Image( + app_icon = ClippingBox( name="notification-icon", - icon_name="notifications", - icon_size=24, + children=Image( + icon_name="notifications", + icon_size=24, + ), ) except Exception as e: logger.warning(f"Failed to load cached header icon: {e}") # Ultimate fallback - app_icon = Image( + app_icon = ClippingBox( name="notification-icon", - icon_name="notifications", - icon_size=24, + children=Image( + icon_name="notifications", + icon_size=24, + ), ) return CenterBox( @@ -646,11 +531,9 @@ class NotificationWidget(Box): name="notification-content", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), + children=Image(pixbuf=self._get_notification_pixbuf(notification)), ), Box( name="notification-text", @@ -741,7 +624,6 @@ class NotificationWidget(Box): icon_path = icon_path[7:] if not os.path.exists(icon_path): - logger.warning(f"Icon path does not exist: {icon_path}") return get_fallback_notification_icon((width, height)) try: @@ -834,7 +716,6 @@ class NotificationWidget(Box): self, "notification_image_cache_key", None ), ) - logger.debug("Cleaned up caches for manually dismissed notification") else: logger.debug("Preserved caches for timeout/auto-dismissed notification") super().destroy() @@ -854,6 +735,20 @@ class NotificationWidget(Box): # Don't resume timeout here since the notification itself might still be hovered self.set_pointer_cursor(button, "arrow") + @staticmethod + def _get_current_notification_timeout(): + """Get the current notification timeout from config manager.""" + try: + timeout_str = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + from config.data import parse_timeout_string + + return parse_timeout_string(timeout_str) + except Exception as e: + logger.warning(f"Failed to get notification timeout from config: {e}") + return data.NOTIFICATION_TIMEOUT + class NotificationRevealer(SlideRevealer): def __init__( @@ -1061,6 +956,17 @@ class NotificationState: class ModusNoti(Window): def __init__(self): + # Local config state mirroring services.config usage + self._current_config = { + "notification_timeout": data.NOTIFICATION_TIMEOUT_STR, + "notification_ignored_apps": data.NOTIFICATION_IGNORED_APPS_HISTORY, + "notification_limited_apps_history": data.NOTIFICATION_LIMITED_APPS_HISTORY, + } + + # Subscribe to config changes and apply initial config + on_config_change(self._on_config_changed) + self._apply_initial_config() + self._server = notification_service self.notifications = Box( @@ -1085,7 +991,8 @@ class ModusNoti(Window): self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications self._server.connect("notification-added", self.on_new_notification) - super().__init__( + Window.__init__( + self, anchor="top right", child=self.notifications, layer="overlay", @@ -1106,6 +1013,10 @@ class ModusNoti(Window): # Notification is already cached by the service, just don't show popup return + # Check if notification should be ignored based on current config + if self._should_ignore_notification(notification): + return + # Preload assets immediately for optimal caching and display performance preload_notification_assets(notification) @@ -1288,3 +1199,80 @@ class ModusNoti(Window): def get_queue_length(self): return len(self.notification_queue) + + def _should_ignore_notification(self, notification): + """Check if notification should be ignored based on current config.""" + try: + ignored_apps = self._current_config.get("notification_ignored_apps", []) + app_name = getattr(notification, "app_name", "") + + # Check if app name is in ignored list + if app_name in ignored_apps: + return True + + return False + except Exception as e: + logger.warning(f"Failed to check if notification should be ignored: {e}") + return False + + def update_config(self, new_config): + """Update notification configuration dynamically.""" + try: + # Handle notification timeout changes + if "notification_timeout" in new_config: + timeout_str = new_config["notification_timeout"] + # Parse the timeout string to milliseconds + + timeout_ms = parse_timeout_string(timeout_str) + + # Handle ignored apps changes + if "notification_ignored_apps" in new_config: + ignored_apps = new_config["notification_ignored_apps"] + + # Handle limited apps history changes + if "notification_limited_apps_history" in new_config: + limited_apps = new_config["notification_limited_apps_history"] + + # Update the current config + self._current_config.update(new_config) + + except Exception as e: + logger.error(f"[ModusNoti] Failed to update config: {e}") + + def _apply_initial_config(self): + try: + timeout_val = get_config( + "notification_timeout", data.NOTIFICATION_TIMEOUT_STR + ) + ignored_apps = get_config( + "notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY + ) + limited_apps = get_config( + "notification_limited_apps_history", + data.NOTIFICATION_LIMITED_APPS_HISTORY, + ) + initial = { + "notification_timeout": timeout_val, + "notification_ignored_apps": ignored_apps, + "notification_limited_apps_history": limited_apps, + } + # Only apply if different + self.update_config(initial) + except Exception as e: + logger.error(f"[ModusNoti] Failed to apply initial config: {e}") + + def _on_config_changed(self, new_config: dict, old_config: dict): + try: + changes = {} + for key in ( + "notification_timeout", + "notification_ignored_apps", + "notification_limited_apps_history", + ): + if key in new_config and new_config.get(key) != old_config.get(key): + changes[key] = new_config.get(key) + + if changes: + self.update_config(changes) + except Exception as e: + logger.error(f"[ModusNoti] Error handling config change: {e}") diff --git a/modules/notification/notification_center.py b/modules/notification/notification_center.py index 3aa77b0..7479d05 100644 --- a/modules/notification/notification_center.py +++ b/modules/notification/notification_center.py @@ -1,32 +1,32 @@ from collections import defaultdict -import time +from fabric.utils import GLib, logger from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image from fabric.widgets.label import Label from fabric.widgets.revealer import Revealer from fabric.widgets.scrolledwindow import ScrolledWindow -from gi.repository import GLib, GdkPixbuf -from loguru import logger +from fabric.widgets.wayland import WaylandWindow as Window +from config import data from modules.notification.notification import ( NotificationWidget, cache_notification_icon, - cache_notification_image, - get_cached_notification_image, - get_notification_image_cache_key, - preload_notification_assets, cleanup_all_notification_caches, cleanup_notification_specific_caches, - get_fallback_notification_icon, + get_cached_notification_image, + preload_notification_assets, +) +from modules.notification.unified_cache import ( + get_fallback_icon as get_fallback_notification_icon, ) from services.modus import notification_service from utils.functions import escape_markup_text +from widgets.clipping_box import ClippingBox from widgets.custom_image import CustomImage -from widgets.wayland import WaylandWindow as Window -from config import data class ExpandableNotificationGroup(Box): @@ -95,9 +95,9 @@ class ExpandableNotificationGroup(Box): name="stack-main-notification", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( + children=Image( pixbuf=self._get_notification_pixbuf_for_group( latest_notification ) @@ -123,9 +123,11 @@ class ExpandableNotificationGroup(Box): ), Label( name="notification-body", - markup=escape_markup_text(latest_notification._notification.summary.replace( - "\n", " " - )), + markup=escape_markup_text( + latest_notification._notification.summary.replace( + "\n", " " + ) + ), max_chars_width=25, h_align="start", ellipsization="end", @@ -182,9 +184,6 @@ class ExpandableNotificationGroup(Box): notification_image_cache_key ) if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) return cached_image except Exception as e: logger.debug(f"Failed to load cached notification image: {e}") @@ -203,7 +202,6 @@ class ExpandableNotificationGroup(Box): cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") return cached_app_icon except Exception as e: logger.debug(f"Failed to load cached app icon: {e}") @@ -214,15 +212,11 @@ class ExpandableNotificationGroup(Box): if app_icon_source: cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) return cached_app_icon except Exception as e: logger.debug(f"Failed to get directly cached app icon: {e}") # Ultimate fallback - logger.debug("Using fallback notification icon") return get_fallback_notification_icon((35, 35)) def create_expanded_state(self): @@ -333,7 +327,6 @@ class ExpandableNotificationGroup(Box): # Small delay then animate notifications sliding down GLib.timeout_add(50, lambda: self.notifications_revealer.set_reveal_child(True)) - logger.debug(f"Expanded notification group: {self.app_name}") def collapse(self, *args): """Collapse with header sliding up, notifications crossfading, then sliding up""" @@ -351,8 +344,6 @@ class ExpandableNotificationGroup(Box): 260, lambda: self.notifications_revealer.set_reveal_child(False) ) - logger.debug(f"Collapsed notification group: {self.app_name}") - def _show_collapsed_midway(self): """Show collapsed state and hide expanded container to prevent deformation""" self.collapsed_eventbox.set_visible(True) @@ -383,10 +374,6 @@ class ExpandableNotificationGroup(Box): ), ) - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - notification_service.remove_cached_notification(notification.cache_id) except Exception as e: logger.error( @@ -411,12 +398,7 @@ class ExpandableNotificationGroup(Box): ), ) - logger.debug( - f"Cleaned up caches for notification ID: {notification._notification.id}" - ) - notification_service.remove_cached_notification(notification.cache_id) - logger.debug(f"Closed single notification: {notification.cache_id}") except Exception as e: logger.error( f"Error removing single notification {notification.cache_id}: {e}" @@ -476,9 +458,6 @@ class NotificationCenterWidget(NotificationWidget): notification_image_cache_key ) if cached_image: - logger.debug( - f"Using cached notification image: {notification_image_cache_key}" - ) return cached_image except Exception as e: logger.debug(f"Failed to load cached notification image: {e}") @@ -492,7 +471,6 @@ class NotificationCenterWidget(NotificationWidget): cached_app_icon = get_from_cache(app_icon_cache_key, (35, 35)) if cached_app_icon: - # logger.debug(f"Using cached app icon: {app_icon_cache_key}") return cached_app_icon except Exception as e: logger.debug(f"Failed to load cached app icon: {e}") @@ -503,15 +481,11 @@ class NotificationCenterWidget(NotificationWidget): if app_icon_source: cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) if cached_app_icon: - logger.debug( - f"Using directly cached app icon for: {app_icon_source}" - ) return cached_app_icon except Exception as e: logger.debug(f"Failed to get directly cached app icon: {e}") # Ultimate fallback - logger.debug("Using fallback notification icon") return get_fallback_notification_icon((35, 35)) def create_content(self, notification): @@ -537,11 +511,9 @@ class NotificationCenterWidget(NotificationWidget): name="notification-content", spacing=8, children=[ - Box( + ClippingBox( name="notification-image", - children=CustomImage( - pixbuf=self._get_notification_pixbuf(notification) - ), + children=Image(pixbuf=self._get_notification_pixbuf(notification)), ), Box( name="notification-text", @@ -554,7 +526,9 @@ class NotificationCenterWidget(NotificationWidget): children=[ Label( name="notification-summary", - markup=escape_markup_text(notification.summary.replace("\n", " ")), + markup=escape_markup_text( + notification.summary.replace("\n", " ") + ), h_align="start", max_chars_width=25, ellipsization="end", @@ -563,7 +537,9 @@ class NotificationCenterWidget(NotificationWidget): ), ( Label( - markup=escape_markup_text(notification.body.replace("\n", " ")), + markup=escape_markup_text( + notification.body.replace("\n", " ") + ), h_align="start", max_chars_width=35, ellipsization="end", @@ -609,10 +585,6 @@ class NotificationCenterWidget(NotificationWidget): ), ) - logger.debug( - f"Cleaned up caches for notification center ID: {self.notification.id}" - ) - notification_service.remove_cached_notification(self.notification_id) except Exception as e: logger.error(f"Error removing notification {self.notification_id}: {e}") @@ -700,7 +672,6 @@ class NotificationCenter(Window): self._rebuild_notification_groups() self.add_keybinding("Escape", self._on_escape_pressed) - self.connect("destroy", self._on_destroy) def _rebuild_notification_groups(self): """Rebuild notification groups from scratch with enhanced asset preloading and debugging""" @@ -728,12 +699,6 @@ class NotificationCenter(Window): self.notification_groups[app_name].append(cached_notification) rebuild_count += 1 - logger.info( - f"Rebuilt {rebuild_count} notifications into { - len(self.notification_groups) - } groups" - ) - # Create group widgets and handle limited apps for app_name, notifications in self.notification_groups.items(): # Sort notifications by ID (highest ID first - newest notifications) @@ -813,7 +778,6 @@ class NotificationCenter(Window): self.notifications_box.pack_start(group_widget, False, False, 0) group_widget.show_all() - logger.debug(f"Added notification to group {app_name}") except Exception as e: logger.error(f"Error adding notification to group: {e}") @@ -864,7 +828,6 @@ class NotificationCenter(Window): ), ) - logger.debug(f"Removed notification from group {app_name}") except Exception as e: logger.error(f"Error removing notification from group: {e}") @@ -878,7 +841,6 @@ class NotificationCenter(Window): cleanup_all_notification_caches() for child in self.notifications_box.get_children(): child.destroy() - logger.debug("Cleared all notification groups and remaining cached images") except Exception as e: logger.error(f"Error clearing notification groups: {e}") @@ -916,8 +878,3 @@ class NotificationCenter(Window): self.main_revealer.set_reveal_child(True) else: self.main_revealer.set_reveal_child(False) - logger.debug(f"Notification center visibility set to: {visible}") - - def _on_destroy(self, *_): - # Signals will be automatically disconnected when the object is destroyed - pass diff --git a/modules/notification/unified_cache.py b/modules/notification/unified_cache.py index 0bd673b..3777989 100644 --- a/modules/notification/unified_cache.py +++ b/modules/notification/unified_cache.py @@ -1,11 +1,8 @@ -import os import hashlib import time import uuid -from fabric.utils import get_relative_path -from gi.repository import GdkPixbuf -from loguru import logger +from fabric.utils import GdkPixbuf, get_relative_path, logger, os import config.data as data @@ -67,7 +64,6 @@ def save_to_cache(pixbuf, cache_key, size=None): ) pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification asset: {cache_key}") return cache_path, cache_key except Exception as e: logger.warning(f"Failed to cache notification asset: {e}") @@ -103,7 +99,6 @@ def cleanup_cache(cache_key=None): ) if os.path.exists(cache_path): os.unlink(cache_path) - logger.debug(f"Cleaned up cached asset: {cache_key}") else: # Remove all cached assets for filename in os.listdir(UNIFIED_NOTIFICATION_CACHE_DIR): @@ -111,7 +106,6 @@ def cleanup_cache(cache_key=None): filepath = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, filename) try: os.unlink(filepath) - logger.debug(f"Cleaned up cached asset: {filename}") except Exception as e: logger.warning(f"Failed to cleanup cache file {filename}: {e}") except Exception as e: @@ -134,7 +128,6 @@ def cleanup_old_cache_files(): file_mtime = os.path.getmtime(filepath) if file_mtime < week_ago: os.unlink(filepath) - logger.debug(f"Cleaned up old cache: {filename}") except Exception as e: logger.warning(f"Failed to cleanup cache file {filename}: {e}") except Exception as e: @@ -153,15 +146,11 @@ def verify_cache_persistence(): if f.endswith(".png") ] - logger.info(f"Cache persistence check: {len(cache_files)} assets cached") - # Test loading a few cached items to verify they work for cache_file in cache_files[:2]: # Test first 2 files try: cache_path = os.path.join(UNIFIED_NOTIFICATION_CACHE_DIR, cache_file) test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) - if test_pixbuf: - logger.debug(f"Successfully verified cached asset: {cache_file}") except Exception as e: logger.warning(f"Failed to load cached asset {cache_file}: {e}") @@ -175,7 +164,7 @@ def verify_cache_persistence(): def get_fallback_icon(size=(48, 48)): """Get the fallback notification icon""" try: - fallback_path = get_relative_path("../../config/assets/icons/notification.png") + fallback_path = get_relative_path("../../assets/icons/notification.png") return GdkPixbuf.Pixbuf.new_from_file_at_scale( fallback_path, size[0], size[1], True ) @@ -194,4 +183,3 @@ def get_fallback_icon(size=(48, 48)): ensure_cache_dir() cleanup_old_cache_files() verify_cache_persistence() - diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py index ac7e63b..174f33b 100644 --- a/modules/panel/components/enhanced_system_tray.py +++ b/modules/panel/components/enhanced_system_tray.py @@ -1,21 +1,17 @@ """ -Enhanced System Tray Icon Handling +Simplified System Tray Icon Patch -This module provides enhanced icon loading capabilities for system tray items, -including fallback mechanisms for file paths and common icon locations. +Focuses on loading icons from absolute file paths and fixing tooltip naming. """ -import os - -from gi.repository import GdkPixbuf, Gtk +from pathlib import Path from fabric.system_tray.widgets import SystemTrayItem - -# FIX: the tooltip should show application names instead of unknown +from fabric.utils import GdkPixbuf, GLib def patched_do_update_properties(self, *_): - # Try default GTK theme first + # Determine the icon name to use icon_name = self._item.icon_name attention_icon_name = self._item.attention_icon_name @@ -24,142 +20,39 @@ def patched_do_update_properties(self, *_): else: preferred_icon_name = icon_name - # Try to load from default GTK theme - if preferred_icon_name: - try: - default_theme = Gtk.IconTheme.get_default() - if default_theme.has_icon(preferred_icon_name): - pixbuf = default_theme.load_icon( - preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE + # 1. Try the standard fabric/GTK implementation first + pixbuf = self._item.get_preferred_icon_pixbuf(self._icon_size) + + # 2. If standard lookup fails, try your specific absolute path logic + if pixbuf is None and preferred_icon_name: + path = Path(preferred_icon_name) + if path.is_absolute() and path.exists(): + try: + target_size = self._icon_size if self._icon_size else 24 + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), + target_size, + target_size, + True, # Preserve aspect ratio ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - # Set tooltip - tooltip = self._item.tooltip - self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" - ) - return - except: - pass + except GLib.GError: + pixbuf = None - # Enhanced fallback handling for file paths - if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name): - return + # Apply the resulting pixbuf or the fallback "missing" icon + if pixbuf: + self._image.set_from_pixbuf(pixbuf) + else: + self._image.set_from_icon_name("image-missing", self._icon_size) - # Fallback to original implementation - original_do_update_properties(self, *_) - - -def _try_load_icon_from_path(self, icon_path): - try: - # Check if it's a file path and handle it directly - if os.path.isabs(icon_path) or "/" in icon_path: - # Try to load as SVG from the original path if it exists - if os.path.exists(icon_path): - if icon_path.lower().endswith(".svg"): - # Load SVG directly - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - else: - # Load other image formats - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - icon_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it's a file path, try to extract just the filename for theme lookup - filename = os.path.basename(icon_path) - if filename: - # Remove extension for theme lookup - name_without_ext = os.path.splitext(filename)[0] - default_theme = Gtk.IconTheme.get_default() - - # Try filename without extension - if default_theme.has_icon(name_without_ext): - pixbuf = default_theme.load_icon( - name_without_ext, - self._icon_size, - Gtk.IconLookupFlags.FORCE_SIZE, - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # Try full filename - if default_theme.has_icon(filename): - pixbuf = default_theme.load_icon( - filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - - # If it looks like a file path but doesn't exist, try common icon locations - if os.path.isabs(icon_path): - common_icon_dirs = [ - "/usr/share/icons", - "/usr/share/pixmaps", - "/usr/local/share/icons", - "/usr/local/share/pixmaps", - os.path.expanduser("~/.local/share/icons"), - os.path.expanduser("~/.icons"), - ] - - filename = os.path.basename(icon_path) - for icon_dir in common_icon_dirs: - potential_path = os.path.join(icon_dir, filename) - if os.path.exists(potential_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( - potential_path, self._icon_size, self._icon_size - ) - if pixbuf: - self._image.set_from_pixbuf(pixbuf) - self._set_tooltip() - return True - except: - continue - - except Exception: - pass - - return False - - -def _set_tooltip(self): tooltip = self._item.tooltip self.set_tooltip_markup( - tooltip.description or tooltip.title or self._item.title.title() - if self._item.title - else "Unknown" + tooltip.description + or tooltip.title + or (self._item.title.title() if self._item.title else None) + or "Unknown" ) def apply_enhanced_system_tray(): - # Store original method - global original_do_update_properties - original_do_update_properties = SystemTrayItem.do_update_properties - - # Attach helper methods to SystemTrayItem class - SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path - SystemTrayItem._set_tooltip = _set_tooltip - - # Replace the do_update_properties method + # Replace the method on the class so all tray items use this logic SystemTrayItem.do_update_properties = patched_do_update_properties - - -# Store reference to original method -original_do_update_properties = None diff --git a/modules/panel/components/globalmenu.py b/modules/panel/components/globalmenu.py new file mode 100644 index 0000000..758fc3e --- /dev/null +++ b/modules/panel/components/globalmenu.py @@ -0,0 +1,338 @@ +from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow +from fabric.utils import FormattedString, exec_shell_command_async +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label + +from utils.app_name_resolver import format_window +from utils.roam import modus_service +from utils.utils import setup_cursor_hover +from widgets.about import About, AboutApp +from widgets.dropdown import ModusDropdown, dropdown_divider, dropdowns +from widgets.mousecapture import DropDownMouseCapture + + +def has_active_window(): + return ( + modus_service.current_active_app_name + and modus_service.current_active_app_name != "Finder" + ) + + +def create_menu_button(label, on_clicked=None): + button_kwargs = { + "label": label, + "name": "global-menu", + } + + if on_clicked is not None: + button_kwargs["on_clicked"] = on_clicked + + button = Button(**button_kwargs) + setup_cursor_hover(button, "pointer") + return button + + +def create_dropdown_with_capture( + dropdown_id, parent, dropdown_children, layer="bottom" +): + dropdown = ModusDropdown( + dropdown_id=dropdown_id, + parent=parent, + dropdown_children=dropdown_children, + ) + mouse_capture = DropDownMouseCapture(layer=layer, child_window=dropdown) + return mouse_capture + + +def manage_button_style_classes(buttons, active_button=None, style_class="active"): + for button in buttons: + if button: # Check if button exists (some might be None) + button.remove_style_class(style_class) + + if active_button: + active_button.add_style_class(style_class) + + +def show_about_app(_=None): + if not has_active_window(): + return + + app_name = modus_service.current_active_app_name + about_window = AboutApp(app_name=app_name, wmclass="") + about_window.toggle(None) + + +def on_click_subthread(button, on_clicked, on_click): + if on_clicked: + on_clicked(button) + else: + exec_shell_command_async(on_click, lambda *_: None) + + for dropdown in dropdowns: + if dropdown.is_visible(): + dropdown.hide_via_mousecapture() + break + + +def dropdown_option( + label: str = "", + keybind: str = "", + on_click='echo "ModusPanelDropdown Action"', + on_clicked=None, +): + btn = Button( + child=CenterBox( + start_children=[ + Label(label=label, h_align="start", name="dropdown-option-label"), + ], + end_children=[ + Label(label=keybind, h_align="end", name="dropdown-option-keybind") + ], + orientation="horizontal", + h_align="fill", + h_expand=True, + v_expand=True, + ), + name="dropdown-option", + h_align="fill", + on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click), + h_expand=True, + v_expand=True, + ) + setup_cursor_hover(btn, "pointer") + return btn + + +class SystemDropdown(ModusDropdown): + def __init__(self, parent, **kwargs): + super().__init__( + dropdown_id="os-menu", + parent=parent, + dropdown_children=[ + dropdown_option( + "About this PC", on_clicked=lambda _: About().toggle(_) + ), + dropdown_divider("---------------------"), + dropdown_option( + "System Settings...", + # TODO: Open Modus own setting + # on_click="xdg-open settings", + ), + dropdown_divider("---------------------"), + dropdown_option("Force Quit", "", "hyprctl kill"), + dropdown_divider("---------------------"), + dropdown_option("Sleep", "", "systemctl suspend"), + dropdown_option("Restart", "", "systemctl reboot"), + dropdown_option("Shut Down", "", "shutdown now"), + dropdown_divider("---------------------"), + dropdown_option("Lock Screen", "󰘳 L", "hyprlock"), + ], + **kwargs, + ) + + +class GlobalMenuDropdowns: + def __init__(self, parent): + self.parent = parent + + self.system_dropdown = SystemDropdown(parent=parent) + self.menu_button_dropdown = DropDownMouseCapture( + layer="bottom", child_window=self.system_dropdown + ) + self.menu_button = Button( + label="Modus", + name="global-menu", + on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), + ) + self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) + + self.global_title_menu_about = dropdown_option( + f"About {modus_service.current_active_app_name}", + on_clicked=show_about_app, + ) + self.global_menu_title = create_dropdown_with_capture( + "global-menu-title", + parent, + [self.global_title_menu_about], + ) + + self.global_menu_view = create_dropdown_with_capture( + "global-menu-view", + parent, + [ + dropdown_option( + "Enter Full Screen", + on_click="hyprctl dispatch fullscreen", + ), + ], + ) + self.global_menu_window = create_dropdown_with_capture( + "global-menu-window", + parent, + [ + dropdown_option( + "Zoom In", + "󰍉 +", + on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')", + ), + dropdown_option( + "Zoom Out", + "󰍉 -", + on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Move Window to Left", + on_click="hyprctl dispatch movewindow l", + ), + dropdown_option( + "Move Window to Right", + on_click="hyprctl dispatch movewindow r", + ), + dropdown_option( + "Cycle Through Windows", + on_click="hyprctl dispatch cyclenext", + ), + dropdown_divider("---------------------"), + dropdown_option("Float", on_click="hyprctl dispatch togglefloating"), + dropdown_option("Quit", on_click="hyprctl dispatch killactive"), + dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), + dropdown_option( + "Toggle Split", on_click="hyprctl dispatch togglesplit" + ), + dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), + dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), + dropdown_option( + "Pin", + on_clicked=lambda _: exec_shell_command_async( + "bash ~/.config/scripts/winpin.sh", lambda *_: None + ), + ), + ], + ) + + self.global_menu_help = create_dropdown_with_capture( + "global-menu-help", + parent, + [ + dropdown_option( + "Modus", + on_click="xdg-open https://github.com/S4NKALP/Modus/issues", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" + ), + ], + ) + + modus_service.connect( + "current-active-app-name-changed", self._on_active_app_changed + ) + + self.global_menu_button_title = Button( + child=ActiveWindow( + formatter=FormattedString( + "{ format_window(win_title, win_class) }", + format_window=format_window, + ) + ), + name="global-menu", + on_clicked=self._on_title_button_clicked, + ) + + self.global_menu_title.child_window.set_pointing_to( + self.global_menu_button_title + ) + # File, Edit and Go buttons are placeholders - no dropdowns implemented yet + self.global_menu_button_file = create_menu_button("File") + self.global_menu_button_edit = create_menu_button("Edit") + self.global_menu_button_go = create_menu_button("Go") + + self.global_menu_button_view = create_menu_button( + "View", + lambda _: self.global_menu_view.toggle_mousecapture(), + ) + self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) + self.global_menu_button_window = create_menu_button( + "Window", + lambda _: self.global_menu_window.toggle_mousecapture(), + ) + self.global_menu_window.child_window.set_pointing_to( + self.global_menu_button_window + ) + self.global_menu_button_help = create_menu_button( + "Help", + lambda _: self.global_menu_help.toggle_mousecapture(), + ) + self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) + + self.all_menu_buttons = [ + self.menu_button, + self.global_menu_button_title, + self.global_menu_button_file, + self.global_menu_button_edit, + self.global_menu_button_view, + self.global_menu_button_go, + self.global_menu_button_window, + self.global_menu_button_help, + ] + + self.dropdown_button_map = { + "os-menu": self.menu_button, + "global-menu-title": self.global_menu_button_title, + "global-menu-file": self.global_menu_button_file, + "global-menu-edit": self.global_menu_button_edit, + "global-menu-view": self.global_menu_button_view, + "global-menu-go": self.global_menu_button_go, + "global-menu-window": self.global_menu_button_window, + "global-menu-help": self.global_menu_button_help, + } + + modus_service.connect("current-dropdown-changed", self.changed_dropdown) + modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) + + def _on_title_button_clicked(self, _): + if has_active_window(): + self.global_menu_title.toggle_mousecapture() + + def _on_active_app_changed(self, _, value): + self.global_title_menu_about.set_property("label", f"About {value}") + + def hide_dropdowns(self, *_): + manage_button_style_classes(self.all_menu_buttons) + + def changed_dropdown(self, _, dropdown_id): + active_button = self.dropdown_button_map.get(dropdown_id) + manage_button_style_classes(self.all_menu_buttons, active_button) + + +class GlobalMenu(Box): + def __init__(self, parent_window=None, **kwargs): + if parent_window is None: + parent_window = kwargs.pop("parent_window", None) + + super().__init__( + name="globalmenu", orientation="horizontal", spacing=0, **kwargs + ) + + self.dropdown_system = GlobalMenuDropdowns(parent=parent_window) + + self.children = [ + self.dropdown_system.global_menu_button_title, + self.dropdown_system.global_menu_button_file, + self.dropdown_system.global_menu_button_edit, + self.dropdown_system.global_menu_button_view, + self.dropdown_system.global_menu_button_go, + self.dropdown_system.global_menu_button_window, + self.dropdown_system.global_menu_button_help, + ] + + def show_system_dropdown(self, imac_button): + self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to( + imac_button + ) + mouse_capture = self.dropdown_system.menu_button_dropdown + mouse_capture.set_child_window_visible(not mouse_capture.is_visible()) diff --git a/modules/panel/components/indicators.py b/modules/panel/components/indicators.py index 7e8e277..d22d393 100644 --- a/modules/panel/components/indicators.py +++ b/modules/panel/components/indicators.py @@ -1,19 +1,59 @@ from fabric.bluetooth import BluetoothClient -from fabric.utils import get_relative_path from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label -from fabric.widgets.svg import Svg +from fabric.widgets.wayland import WaylandWindow as Window +from utils.utils import setup_cursor_hover, svg_file +from widgets.battery_widget import BatteryControl -from modules.controlcenter.battery import BatteryControl from modules.controlcenter.bluetooth import BluetoothConnections from modules.controlcenter.wifi import WifiConnections -from services.battery import Battery +from services.battery import BatteryService, DeviceState from services.network import NetworkClient +from utils.functions import format_duration, get_wifi_icon_for_strength from utils.roam import modus_service -from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon from widgets.mousecapture import DropDownMouseCapture -from widgets.wayland import WaylandWindow as Window + + +def create_control_window(name_prefix): + return Window( + layer="overlay", + title="modus", + anchor="top right", + margin="2px 10px 0px 0px", + exclusivity="auto", + keyboard_mode="on-demand", + name=f"{name_prefix}-window", + visible=False, + ) + + +def create_mouse_capture(window): + return DropDownMouseCapture(layer="top", child_window=window) + + +def setup_control_center( + show_window, name_prefix, widget_class, parent, **widget_kwargs +): + if not show_window: + return None, None, None + + window = create_control_window(name_prefix) + widget = widget_class(parent, **widget_kwargs) + window.children = [widget] + mouse_capture = create_mouse_capture(window) + + return window, widget, mouse_capture + + +def handle_indicator_click(mouse_capture): + if mouse_capture: + mouse_capture.toggle_mousecapture() + + +def hide_control_center(mouse_capture): + if mouse_capture: + mouse_capture.hide_child_window() class BluetoothIndicator(Box): @@ -22,13 +62,7 @@ class BluetoothIndicator(Box): self.show_window = show_window self.bluetooth = BluetoothClient() - self.bt_icon = Svg( - name="bt-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ), - ) + self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22) self.bt_button = Button( name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked @@ -36,30 +70,21 @@ class BluetoothIndicator(Box): self.add(self.bt_button) - # Create Bluetooth control center widget only if show_window is True - if self.show_window: - self.bluetooth_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="bluetooth-control-window", - visible=False, - ) + # Set pointer cursor on hover + setup_cursor_hover(self.bt_button, "pointer") - self.bluetooth_widget = BluetoothConnections(self, show_back_button=False) - self.bluetooth_window.children = [self.bluetooth_widget] - - # Create mouse capture for Bluetooth widget - self.bluetooth_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.bluetooth_window - ) - else: - self.bluetooth_window = None - self.bluetooth_widget = None - self.bluetooth_mousecapture = None + # Setup control center using shared function + ( + self.bluetooth_window, + self.bluetooth_widget, + self.bluetooth_mousecapture, + ) = setup_control_center( + self.show_window, + "bluetooth", + BluetoothConnections, + self, + show_back_button=False, + ) modus_service.connect("bluetooth-changed", self.on_bluetooth_changed) self.bluetooth.connect("changed", self.on_bluetooth_direct_changed) @@ -71,26 +96,14 @@ class BluetoothIndicator(Box): def update_state(self): if not self.bluetooth.enabled: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-off-clear.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg") tooltip = "Bluetooth disabled" else: connected_devices = self.bluetooth.connected_devices if connected_devices: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") if len(connected_devices) >= 1: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-paired.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-paired.svg") device = connected_devices[0] tooltip = f"Connected to {device.alias}" if device.battery_percentage > 0: @@ -98,11 +111,7 @@ class BluetoothIndicator(Box): else: tooltip = f"Connected to {len(connected_devices)} devices" else: - self.bt_icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/applets/bluetooth-clear.svg" - ) - ) + self.bt_icon.dynamic_file("applets/bluetooth-clear.svg") tooltip = "No devices connected" self.bt_button.set_tooltip_text(tooltip) @@ -144,19 +153,13 @@ class BluetoothIndicator(Box): modus_service.bluetooth = bluetooth_state def on_bluetooth_clicked(self, *args): - """Handle Bluetooth indicator click""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.toggle_mousecapture() + handle_indicator_click(self.bluetooth_mousecapture) def close_bluetooth(self, *args): - """Close Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() + hide_control_center(self.bluetooth_mousecapture) def hide_controlcenter(self, *args): - """Hide Bluetooth control center""" - if self.show_window and self.bluetooth_mousecapture: - self.bluetooth_mousecapture.hide_child_window() + hide_control_center(self.bluetooth_mousecapture) class NetworkIndicator(Box): @@ -166,13 +169,7 @@ class NetworkIndicator(Box): self.network_service = NetworkClient() - self.network_icon = Svg( - name="network-icon", - size=22, - svg_file=get_relative_path( - "../../../config/assets/icons/applets/wifi-clear.svg" - ), - ) + self.network_icon = svg_file("applets/wifi-clear.svg", size=22) self.network_button = Button( name="network-button", @@ -182,30 +179,21 @@ class NetworkIndicator(Box): self.add(self.network_button) - # Create WiFi control center widget only if show_window is True - if self.show_window: - self.wifi_window = Window( - layer="overlay", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="wifi-control-window", - visible=False, - ) + # Set pointer cursor on hover + setup_cursor_hover(self.network_button, "pointer") - self.wifi_widget = WifiConnections(self, show_back_button=False) - self.wifi_window.children = [self.wifi_widget] - - # Create mouse capture for WiFi widget - self.wifi_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.wifi_window - ) - else: - self.wifi_window = None - self.wifi_widget = None - self.wifi_mousecapture = None + # Setup control center using shared function + ( + self.wifi_window, + self.wifi_widget, + self.wifi_mousecapture, + ) = setup_control_center( + self.show_window, + "wifi", + WifiConnections, + self, + show_back_button=False, + ) modus_service.connect("wlan-changed", self.on_wlan_changed) self.network_service.connect("wifi-device-added", self.on_wifi_device_added) @@ -230,7 +218,6 @@ class NetworkIndicator(Box): self.update_state() def on_ethernet_device_added(self, *args): - """Called when Ethernet device is added""" if self.network_service.ethernet_device: self.network_service.ethernet_device.connect( "changed", self.on_network_direct_changed @@ -249,7 +236,6 @@ class NetworkIndicator(Box): def update_modus_service_wlan_state(self): wlan_state = "disconnected" - # Check WiFi first (prioritize WiFi over Ethernet) if self.network_service.wifi_device: wifi = self.network_service.wifi_device if not wifi.wireless_enabled: @@ -315,25 +301,17 @@ class NetworkIndicator(Box): icon_file = "network-wired-offline.svg" tooltip = "Ethernet disconnected" - self.network_icon.set_from_file( - get_relative_path(f"../../../config/assets/icons/applets/{icon_file}") - ) + self.network_icon.dynamic_file(f"applets/{icon_file}") self.network_button.set_tooltip_text(tooltip) def on_wifi_clicked(self, *args): - """Handle WiFi indicator click""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.toggle_mousecapture() + handle_indicator_click(self.wifi_mousecapture) def close_wifi(self, *args): - """Close WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() + hide_control_center(self.wifi_mousecapture) def hide_controlcenter(self, *args): - """Hide WiFi control center""" - if self.show_window and self.wifi_mousecapture: - self.wifi_mousecapture.hide_child_window() + hide_control_center(self.wifi_mousecapture) class BatteryIndicator(Box): @@ -341,15 +319,9 @@ class BatteryIndicator(Box): super().__init__(name="battery-indicator", orientation="h", **kwargs) self.show_window = show_window - self.battery_service = Battery() + self.battery_service = BatteryService() - self.battery_icon = Svg( - name="battery-icon", - size=23, - svg_file=get_relative_path( - "../../../config/assets/icons/battery/battery-100.svg" - ), - ) + self.battery_icon = svg_file("battery/battery-100.svg", size=23) self.battery_button = Button( name="battery-button", @@ -362,30 +334,20 @@ class BatteryIndicator(Box): self.add(self.battery_label) self.add(self.battery_button) - # Create Battery control center widget only if show_window is True - if self.show_window: - self.battery_window = Window( - layer="top", - title="modus", - anchor="top right", - margin="2px 10px 0px 0px", - exclusivity="auto", - keyboard_mode="on-demand", - name="battery-control-window", - visible=False, - ) + # Set pointer cursor on hover + setup_cursor_hover(self.battery_button, "pointer") - self.battery_widget = BatteryControl(self, show_back_button=False) - self.battery_window.children = [self.battery_widget] - - # Create mouse capture for Battery widget - self.battery_mousecapture = DropDownMouseCapture( - layer="top", child_window=self.battery_window - ) - else: - self.battery_window = None - self.battery_widget = None - self.battery_mousecapture = None + ( + self.battery_window, + self.battery_widget, + self.battery_mousecapture, + ) = setup_control_center( + self.show_window, + "battery", + BatteryControl, + self, + show_back_button=False, + ) modus_service.connect("battery-changed", self.on_battery_changed) self.battery_service.connect("changed", self.on_battery_direct_changed) @@ -400,21 +362,46 @@ class BatteryIndicator(Box): self.update_modus_service_battery_state() self.update_state() + def _get_percentage(self): + return int(self.battery_service.get_property("Percentage") or 0) + + def _get_state(self): + state_value = self.battery_service.get_property("State") + return DeviceState.get(state_value, "UNKNOWN") + + def _is_present(self): + return bool(self.battery_service.get_property("IsPresent")) + + def _get_time_to_empty(self): + return int(self.battery_service.get_property("TimeToEmpty") or 0) + + def _get_time_to_full(self): + return int(self.battery_service.get_property("TimeToFull") or 0) + + def _format_time(self, seconds: int) -> str: + return format_duration(seconds) + + def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str: + clamped = max(0, min(100, percentage)) + step = (clamped // 10) * 10 + filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg" + return f"battery/{filename}" + def update_modus_service_battery_state(self): - if not self.battery_service.is_present: + if not self._is_present(): battery_state = "not_present" else: - percentage = self.battery_service.percentage - state = self.battery_service.state.lower() + percentage = self._get_percentage() + state_str = self._get_state().lower() - battery_state = f"{state}:{percentage}%" + battery_state = f"{state_str}:{percentage}%" - if state == "discharging": - time_to_empty = self.battery_service.time_to_empty + if state_str == "discharging": + time_to_empty = self._format_time(self._get_time_to_empty()) if time_to_empty != "N/A": battery_state += f":{time_to_empty}" - elif state == "charging": - time_to_full = self.battery_service.time_to_full + elif state_str == "charging": + time_to_full = self._format_time(self._get_time_to_full()) if time_to_full != "N/A": battery_state += f":{time_to_full}" @@ -425,11 +412,11 @@ class BatteryIndicator(Box): if state == "CHARGING": tooltip += " (Charging)" - time_to_full = self.battery_service.time_to_full + time_to_full = self._format_time(self._get_time_to_full()) if time_to_full != "N/A": tooltip += f" - {time_to_full} until full" elif state == "DISCHARGING": - time_to_empty = self.battery_service.time_to_empty + time_to_empty = self._format_time(self._get_time_to_empty()) if time_to_empty != "N/A": tooltip += f" - {time_to_empty} remaining" elif state == "FULLY_CHARGED": @@ -438,40 +425,29 @@ class BatteryIndicator(Box): return tooltip def update_state(self): - if not self.battery_service.is_present: - # Hide the entire battery component when no battery is present + if not self._is_present(): self.set_visible(False) return else: - # Show the battery component when battery is present self.set_visible(True) - percentage = self.battery_service.percentage - state = self.battery_service.state + percentage = self._get_percentage() + state = self._get_state() is_charging = state in ["CHARGING", "FULLY_CHARGED"] - icon_file = Battery.get_battery_icon_file( - percentage, is_charging, base_path="../../../config/assets/icons/" - ) + icon_file = self._get_battery_icon_file(percentage, is_charging) tooltip = self.get_battery_tooltip(percentage, state) percentage_text = f"{percentage}%" - # Update icon, tooltip, and percentage label - self.battery_icon.set_from_file(get_relative_path(icon_file)) + self.battery_icon.dynamic_file(icon_file) self.battery_button.set_tooltip_text(tooltip) self.battery_label.set_label(percentage_text) def on_battery_clicked(self, *args): - """Handle Battery indicator click""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.toggle_mousecapture() + handle_indicator_click(self.battery_mousecapture) def close_battery(self, *args): - """Close Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() + hide_control_center(self.battery_mousecapture) def hide_controlcenter(self, *args): - """Hide Battery control center""" - if self.show_window and self.battery_mousecapture: - self.battery_mousecapture.hide_child_window() + hide_control_center(self.battery_mousecapture) diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py deleted file mode 100644 index 61e7735..0000000 --- a/modules/panel/components/menubar.py +++ /dev/null @@ -1,411 +0,0 @@ -import json -import os -import subprocess - -from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow -from fabric.utils import FormattedString -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.centerbox import CenterBox -from fabric.widgets.label import Label - -from modules.about import About, AboutApp -from utils.roam import modus_service -from widgets.dropdown import ModusDropdown, dropdown_divider -from widgets.mousecapture import DropDownMouseCapture -from utils.app_name_resolver import format_window - - -def show_about_app(): - """Show about dialog for current active application""" - try: - # Use modus_service's Hyprland connection to get current window info - wmclass = "" - title = "" - - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Don't show about dialog if there's no active window (Finder state) - if not wmclass and not title: - return - - app_name = modus_service.current_active_app_name or "Finder" - # Don't show about dialog for Finder - if app_name == "Finder": - return - - about_window = AboutApp(app_name=app_name, wmclass=wmclass) - about_window.toggle(None) - except Exception: - # Fallback: only show if we have a real app name - app_name = modus_service.current_active_app_name or "" - if app_name and app_name != "Finder": - about_window = AboutApp(app_name=app_name, wmclass="") - about_window.toggle(None) - - -def dropdown_option( - label: str = "", - keybind: str = "", - on_click='echo "ModusPanelDropdown Action"', - on_clicked=None, -): - def on_click_subthread(button): - # Execute the action first - if on_clicked: - on_clicked(button) - else: - subprocess.Popen( - f"nohup {on_click} &", - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - # Hide dropdown by finding the current visible dropdown and calling its hide method - from widgets.dropdown import dropdowns - - for dropdown in dropdowns: - if dropdown.is_visible() and hasattr(dropdown, "hide_via_mousecapture"): - dropdown.hide_via_mousecapture() - break - - return Button( - child=CenterBox( - start_children=[ - Label(label=label, h_align="start", name="dropdown-option-label"), - ], - end_children=[ - Label(label=keybind, h_align="end", name="dropdown-option-keybind") - ], - orientation="horizontal", - h_align="fill", - h_expand=True, - v_expand=True, - ), - name="dropdown-option", - h_align="fill", - on_clicked=on_click_subthread, - h_expand=True, - v_expand=True, - ) - - -class SystemDropdown(ModusDropdown): - def __init__(self, parent, **kwargs): - super().__init__( - dropdown_id="os-menu", - parent=parent, - dropdown_children=[ - dropdown_option( - "About this PC", on_clicked=lambda _: About().toggle(_) - ), - dropdown_divider("---------------------"), - dropdown_option( - "System Settings...", - # TODO: Open Modus own setting - # on_click="xdg-open settings", - ), - dropdown_divider("---------------------"), - dropdown_option("Force Quit", "", "hyprctl kill"), - dropdown_divider("---------------------"), - dropdown_option("Sleep", "", "systemctl suspend"), - dropdown_option("Restart...", "", "systemctl reboot"), - dropdown_option("Shut Down...", "", "shutdown now"), - dropdown_divider("---------------------"), - dropdown_option("Lock Screen", "󰘳 L", "hyprlock"), - ], - **kwargs, - ) - - -class MenuBarDropdowns: - def __init__(self, parent): - self.parent = parent - - # System dropdown - self.system_dropdown = SystemDropdown(parent=parent) - self.menu_button_dropdown = DropDownMouseCapture( - layer="bottom", child_window=self.system_dropdown - ) - self.menu_button = Button( - label="Modus", - name="menu-button", - style_classes="button", - on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), - ) - self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) - - # Global menu dropdowns - self.global_title_menu_about = dropdown_option( - f"About {modus_service.current_active_app_name}", - on_clicked=lambda _: show_about_app(), - ) - self.global_menu_title = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-title", - parent=parent, - dropdown_children=[self.global_title_menu_about], - ), - ) - - self.global_menu_file = None - self.global_menu_edit = None - self.global_menu_view = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-view", - parent=parent, - dropdown_children=[ - dropdown_option( - "Enter Full Screen", - on_click="hyprctl dispatch fullscreen", - ), - ], - ), - ) - self.global_menu_go = None - self.global_menu_window = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-window", - parent=parent, - dropdown_children=[ - dropdown_option( - "Zoom In", - "󰍉 +", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')", - ), - dropdown_option( - "Zoom Out", - "󰍉 -", - on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Move Window to Left", - on_click="hyprctl dispatch movewindow l", - ), - dropdown_option( - "Move Window to Right", - on_click="hyprctl dispatch movewindow r", - ), - dropdown_option( - "Cycle Through Windows", - on_click="hyprctl dispatch cyclenext", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Float", on_click="hyprctl dispatch togglefloating" - ), - dropdown_option("Quit", on_click="hyprctl dispatch killactive"), - dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), - dropdown_option( - "Toggle Split", on_click="hyprctl dispatch togglesplit" - ), - dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), - dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), - dropdown_option( - "Pin", - on_clicked=lambda _: subprocess.run( - "bash ~/.config/scripts/winpin.sh", shell=True - ), - ), - ], - ), - ) - - self.global_menu_help = DropDownMouseCapture( - layer="bottom", - child_window=ModusDropdown( - dropdown_id="global-menu-help", - parent=parent, - dropdown_children=[ - dropdown_option( - "Modus", - on_click="xdg-open https://github.com/S4NKALP/Modus/issues", - ), - dropdown_divider("---------------------"), - dropdown_option( - "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" - ), - ], - ), - ) - - # Create menu buttons - modus_service.connect( - "current-active-app-name-changed", - lambda _, value: self.global_title_menu_about.set_property( - "label", f"About {value}" - ), - ) - - # Connect to active app name changes to update the title button - modus_service.connect("current-active-app-name-changed", self._on_active_app_changed) - - self.global_menu_button_title = Button( - child=ActiveWindow( - formatter=FormattedString( - "{ format_window(win_title, win_class) }", - format_window=format_window, - ) - ), - name="global-title-button", - style_classes="button", - on_clicked=self._on_title_button_clicked, - ) - - self.global_menu_title.child_window.set_pointing_to( - self.global_menu_button_title - ) - - self.global_menu_button_file = Button( - label="File", name="global-menu-button-file", style_classes="button" - ) - self.global_menu_button_edit = Button( - label="Edit", name="global-menu-button-edit", style_classes="button" - ) - self.global_menu_button_view = Button( - label="View", - name="global-menu-button-view", - style_classes="button", - on_clicked=lambda _: self.global_menu_view.toggle_mousecapture(), - ) - self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) - self.global_menu_button_go = Button( - label="Go", name="global-menu-button-go", style_classes="button" - ) - self.global_menu_button_window = Button( - label="Window", - name="global-menu-button-window", - style_classes="button", - on_clicked=lambda _: self.global_menu_window.toggle_mousecapture(), - ) - self.global_menu_window.child_window.set_pointing_to( - self.global_menu_button_window - ) - self.global_menu_button_help = Button( - label="Help", - name="global-menu-button-help", - style_classes="button", - on_clicked=lambda _: self.global_menu_help.toggle_mousecapture(), - ) - self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) - - modus_service.connect("current-dropdown-changed", self.changed_dropdown) - modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) - - def _on_title_button_clicked(self, _): - """Handle title button click - only show dropdown if there's an active window""" - try: - # Use modus_service's Hyprland connection to get current window info - if ( - hasattr(modus_service, "_hyprland_connection") - and modus_service._hyprland_connection - ): - window_data = modus_service._hyprland_connection.send_command( - "j/activewindow" - ).reply - if window_data: - window_info = json.loads(window_data.decode("utf-8")) - wmclass = window_info.get("class", "") - title = window_info.get("title", "") - - # Only show dropdown if there's an active window (not Finder) - if wmclass or title: - self.global_menu_title.toggle_mousecapture() - return - - # Fallback: check if current_active_app_name is not "Finder" - if ( - modus_service.current_active_app_name - and modus_service.current_active_app_name != "Finder" - ): - self.global_menu_title.toggle_mousecapture() - except Exception: - # If we can't get window info, don't show dropdown - pass - - def _on_active_app_changed(self, _, value): - """Handle active app name changes""" - # Update the "About" menu item label - self.global_title_menu_about.set_property("label", f"About {value}") - - def hide_dropdowns(self, *_): - self.menu_button.remove_style_class("active") - self.global_menu_button_edit.remove_style_class("active") - self.global_menu_button_file.remove_style_class("active") - self.global_menu_button_go.remove_style_class("active") - self.global_menu_button_help.remove_style_class("active") - self.global_menu_button_title.remove_style_class("active") - self.global_menu_button_view.remove_style_class("active") - self.global_menu_button_window.remove_style_class("active") - - def changed_dropdown(self, _, dropdown_id): - self.hide_dropdowns(_, True) - match dropdown_id: - case "os-menu": - self.menu_button.add_style_class("active") - case "global-menu-edit": - self.global_menu_button_edit.add_style_class("active") - case "global-menu-file": - self.global_menu_button_file.add_style_class("active") - case "global-menu-go": - self.global_menu_button_go.add_style_class("active") - case "global-menu-help": - self.global_menu_button_help.add_style_class("active") - case "global-menu-title": - self.global_menu_button_title.add_style_class("active") - case "global-menu-view": - self.global_menu_button_view.add_style_class("active") - case "global-menu-window": - self.global_menu_button_window.add_style_class("active") - case _: - pass - - -class MenuBar(Box): - """Main MenuBar widget that contains all menu buttons""" - - def __init__(self, parent_window=None, **kwargs): - # Extract parent_window from kwargs if not provided as parameter - if parent_window is None: - parent_window = kwargs.pop("parent_window", None) - - super().__init__(name="menubar", orientation="horizontal", spacing=0, **kwargs) - - # Create the dropdown system - self.dropdown_system = MenuBarDropdowns(parent=parent_window) - - # Add all the menu buttons to the menubar - self.children = [ - self.dropdown_system.global_menu_button_title, - self.dropdown_system.global_menu_button_file, - self.dropdown_system.global_menu_button_edit, - self.dropdown_system.global_menu_button_view, - self.dropdown_system.global_menu_button_go, - self.dropdown_system.global_menu_button_window, - self.dropdown_system.global_menu_button_help, - ] - - def show_system_dropdown(self, imac_button): - self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to( - imac_button - ) - mouse_capture = self.dropdown_system.menu_button_dropdown - if mouse_capture.is_visible(): - mouse_capture.set_child_window_visible(False) - else: - mouse_capture.set_child_window_visible(True) diff --git a/modules/panel/components/recording_indicator.py b/modules/panel/components/recording_indicator.py index ab318e3..996f0e8 100644 --- a/modules/panel/components/recording_indicator.py +++ b/modules/panel/components/recording_indicator.py @@ -1,13 +1,12 @@ -import os -import subprocess import time -from fabric.utils import get_relative_path +from fabric.utils import GLib, get_relative_path, os from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.label import Label -from fabric.widgets.svg import Svg -from gi.repository import GLib +from utils.utils import setup_cursor_hover, svg_file + +from utils.functions import is_app_running, kill_process class RecordingIndicator(Button): @@ -24,13 +23,8 @@ class RecordingIndicator(Button): self.timer_timeout_id = None self.status_timeout_id = None - self.recording_icon = Svg( - name="indicators-icon", - size=24, - svg_file=get_relative_path( - "../../../config/assets/icons/misc/media-record.svg" - ), - ) + self.recording_icon = svg_file("misc/media-record.svg", size=24) + self.time_label = Label( name="recording-time-label", markup="00:00", @@ -47,9 +41,19 @@ class RecordingIndicator(Button): self.add(self.recording_box) + # Prevent container.show_all() from forcing this visible when no recording + try: + self.set_no_show_all(True) + except Exception: + pass + self.connect("clicked", self.on_stop_recording) + try: + setup_cursor_hover(self, "pointer") + except Exception: + pass self.connect("button-press-event", self.on_button_press) - self.hide() + self.set_visible(False) GLib.timeout_add(100, self._delayed_init) @@ -58,22 +62,13 @@ class RecordingIndicator(Button): return False def is_recorder_running(self): - # add more process names if needed + # Check if any recorder process is running recorder_processes = ["wf-recorder", "gpu-screen-recorder"] - try: - for proc in recorder_processes: - result = subprocess.run( - ["pgrep", "-x", proc], - capture_output=True, - text=True, - timeout=1, - ) - if result.returncode == 0: - return True # Found a running recorder process - return False # None found running - except Exception: - return False + for proc in recorder_processes: + if is_app_running(proc): + return True + return False def check_recording_status(self): current_time = time.time() @@ -95,9 +90,9 @@ class RecordingIndicator(Button): self.update_timer_display() else: - if self.get_visible(): - self.set_visible(False) - self.cleanup_recording_state() + # Ensure it stays hidden when not recording + self.set_visible(False) + self.cleanup_recording_state() except Exception as e: print(f"[DEBUG] Error checking recording status: {e}") @@ -172,11 +167,9 @@ class RecordingIndicator(Button): def send_stop_command(): try: - subprocess.Popen( - [self.script_path, "record", "stop"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) + # Kill any running recorder processes + kill_process("wf-recorder") + kill_process("gpu-screen-recorder") except Exception as e: print(f"[DEBUG] Error sending stop command: {e}") diff --git a/modules/panel/components/workspace.py b/modules/panel/components/workspace.py new file mode 100644 index 0000000..8ceebc6 --- /dev/null +++ b/modules/panel/components/workspace.py @@ -0,0 +1,79 @@ +from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton +from fabric.widgets.box import Box + +from services.config import get_config, on_config_change +from utils.functions import is_special_workspace_id +from utils.utils import setup_cursor_hover + +# TODO: Support multi-monitor setups + + +class WorkspaceIndicator(Box): + def __init__(self, **kwargs): + Box.__init__( + self, name="workspace-indicator", orientation="h", spacing=4, **kwargs + ) + self._current_config = {"hide_special_workspace": True} + on_config_change(self._on_config_changed) + + self.workspaces = HyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=self._get_button_factory(), + ) + + self.add(self.workspaces) + self.show_all() + + self._apply_initial_config() + + def _apply_initial_config(self): + new_value = get_config("hide_special_workspace", True) + if new_value != self._current_config["hide_special_workspace"]: + self._current_config["hide_special_workspace"] = new_value + self.update_config({"hide_special_workspace": new_value}) + + def _on_config_changed(self, new_config: dict, old_config: dict): + if "hide_special_workspace" in new_config and new_config.get( + "hide_special_workspace" + ) != old_config.get("hide_special_workspace"): + self._current_config["hide_special_workspace"] = new_config.get( + "hide_special_workspace", True + ) + self.update_config( + { + "hide_special_workspace": self._current_config[ + "hide_special_workspace" + ] + } + ) + + def _get_button_factory(self): + if self._current_config.get("hide_special_workspace", True): + return self._create_filtered_button + else: + return lambda ws_id: self._create_button_with_hover(ws_id) + + def _create_filtered_button(self, ws_id): + if self._current_config.get( + "hide_special_workspace", True + ) and is_special_workspace_id(ws_id): + return None + return self._create_button_with_hover(ws_id) + + def _create_button_with_hover(self, ws_id): + button = WorkspaceButton(id=ws_id, label=str(ws_id)) + setup_cursor_hover(button, "pointer") + return button + + def update_config(self, new_config: dict): + if "hide_special_workspace" in new_config: + button_factory = self._get_button_factory() + self.remove(self.workspaces) + self.workspaces = HyprlandWorkspaces( + name="workspaces", + spacing=4, + buttons_factory=button_factory, + ) + self.add(self.workspaces) + self.workspaces.show_all() diff --git a/modules/panel/main.py b/modules/panel/main.py index 9008264..8d638cf 100644 --- a/modules/panel/main.py +++ b/modules/panel/main.py @@ -1,14 +1,14 @@ -from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton from fabric.system_tray.widgets import SystemTray -from fabric.utils import get_relative_path from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.centerbox import CenterBox from fabric.widgets.datetime import DateTime from fabric.widgets.revealer import Revealer -from fabric.widgets.svg import Svg +from fabric.widgets.wayland import WaylandWindow as Window +from modules.panel.components.globalmenu import GlobalMenu +from modules.panel.components.workspace import WorkspaceIndicator +from utils.utils import setup_cursor_hover, svg_file -import config.data as data from modules.controlcenter.main import ModusControlCenter from modules.notification.notification_center import NotificationCenter from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray @@ -17,53 +17,38 @@ from modules.panel.components.indicators import ( BluetoothIndicator, NetworkIndicator, ) -from modules.panel.components.menubar import MenuBar from modules.panel.components.recording_indicator import RecordingIndicator -from modules.todo.todo_widget import TodoListCapture +from services.config import get_config, on_config_change from services.modus import notification_service -from utils.functions import is_special_workspace_id from utils.roam import modus_service from widgets.mousecapture import MouseCapture -from widgets.wayland import WaylandWindow as Window -# Apply enhanced system tray icon handling apply_enhanced_system_tray() class Panel(Window): def __init__(self, **kwargs): super().__init__( - name="bar", + name="panel-window", title="modus", layer="top", anchor="left top right", exclusivity="auto", - visible=False, - ) - - self.launcher = kwargs.get("launcher", None) - self.menubar = MenuBar(parent_window=self) - - self.workspace_indicator = HyprlandWorkspaces( - name="workspaces", - spacing=4, - buttons_factory=lambda ws_id: ( - None - if data.HIDE_SPECIAL_WORKSPACE and is_special_workspace_id(ws_id) - else WorkspaceButton(id=ws_id, label=str(ws_id)) - ), + pass_through=False, + visible=True, + all_visible=False, ) + self.globalmenu = GlobalMenu(parent_window=self) self.imac = Button( name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path("../../config/assets/icons/misc/logo.svg"), - ), - on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac), + child=svg_file("misc/logo.svg", size=18), + on_clicked=lambda *_: self.globalmenu.show_system_dropdown((self.imac)), ) + setup_cursor_hover(self.imac, "pointer") + + self.tray = SystemTray(name="panel-button", spacing=4, icon_size=20) - self.tray = SystemTray(name="system-tray", spacing=4, icon_size=20) self.tray_revealer = Revealer( name="tray-revealer", child=self.tray, @@ -74,110 +59,67 @@ class Panel(Window): self.chevron_button = Button( name="panel-button", - child=Svg( - size=16, - svg_file=get_relative_path( - "../../config/assets/icons/misc/chevron-right.svg" - ), - ), + child=svg_file("misc/chevron-right.svg", size=16), on_clicked=self.toggle_tray, ) + setup_cursor_hover(self.chevron_button, "pointer") self.indicators = Box( name="indicators", orientation="h", spacing=4, - children=[ - BatteryIndicator(), - NetworkIndicator(), - BluetoothIndicator(), - ], ) self.search = Button( - name="panel-button", - on_clicked=lambda *_: self.search_apps(), - child=Svg( - size=22, - svg_file=get_relative_path("../../config/assets/icons/misc/search.svg"), - ), + name="panel-button", child=svg_file("misc/search.svg", size=22) ) + setup_cursor_hover(self.search, "pointer") self.control_center = MouseCapture( layer="top", child_window=ModusControlCenter() ) - self.control_center_button = Button( - name="control-center-button", - style_classes="button", + self.control_center_btn = Button( + name="panel-button", + child=svg_file("misc/control-center.svg", size=22), on_clicked=self.control_center.toggle_mousecapture, - child=Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/misc/control-center.svg" - ), - ), ) + setup_cursor_hover(self.control_center_btn, "pointer") - # Notification Center with MouseCapture self.notification_center = MouseCapture( layer="overlay", child_window=NotificationCenter() ) - # Todo List with MouseCapture - self.todo_list = TodoListCapture() - - # Notification Center Icon - self.notification_icon = Svg( - size=22, - svg_file=get_relative_path( - "../../config/assets/icons/notifications/notification-inactive.svg" - ), + self.notification_icon = svg_file( + "notifications/notification-inactive.svg", size=22 ) - self.notification_center_icon_button = Button( - name="notification-center-icon-button", + self.notification_center_btn = Button( + name="panel-button", child=self.notification_icon, on_clicked=self.on_notification_icon_clicked, ) + setup_cursor_hover(self.notification_center_btn, "pointer") - # Clickable DateTime for todo list - self.datetime_button = Button( - name="datetime-button", + self.datetime_btn = Button( + name="panel-button", child=DateTime(name="date-time", formatters=["%a %-d %b %I:%M %P"]), - on_clicked=self.on_datetime_clicked, ) + setup_cursor_hover(self.datetime_btn, "pointer") + self.workspace_indicator = WorkspaceIndicator() self.recording_indicator = RecordingIndicator() + # Create boxes and mount, to allow live updates + self.left_box = Box(name="modules-left") + self.center_box = Box(name="modules-center", children=self.recording_indicator) + self.right_box = Box(name="modules-right", spacing=4, orientation="h") + self.children = CenterBox( name="panel", - start_children=Box( - name="modules-left", - children=[ - self.imac, - self.menubar, - ], - ), - center_children=Box( - name="modules-center", - children=self.recording_indicator, - ), - end_children=Box( - name="modules-right", - spacing=4, - orientation="h", - children=[ - self.workspace_indicator, - self.tray_revealer, - self.chevron_button, - self.indicators, - self.search, - self.control_center_button, - self.datetime_button, - self.notification_center_icon_button, - ], - ), + start_children=self.left_box, + center_children=self.center_box, + end_children=self.right_box, ) # Connect to DND state changes for notification icon @@ -191,46 +133,28 @@ class Panel(Window): # Set initial notification icon state self.update_notification_icon() - return self.show_all() + # Initial layout build + self._rebuild_layout_from_config() - def search_apps(self): - self.launcher.show_launcher() + # Live updates + on_config_change(self._on_config_changed) - def toggle_tray(self, *_): - current_state = self.tray_revealer.child_revealed - self.tray_revealer.child_revealed = not current_state - - if self.tray_revealer.child_revealed: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-left.svg") - ) - else: - self.chevron_button.get_child().set_from_file( - get_relative_path("../../config/assets/icons/misc/chevron-right.svg") - ) + self.show_all() def on_dnd_changed(self, _, dnd_state): - """Handle DND state changes from the service.""" self.update_notification_icon() # Update notification icon when DND changes def on_notification_count_changed(self, service, *args): - """Handle notification count changes from the service.""" self.update_notification_icon() def on_notification_icon_clicked(self, *args): - """Handle notification icon clicks - only open center if there are notifications.""" count = notification_service.count if count > 0: # Only open notification center if there are notifications self.notification_center.toggle_mousecapture() # Do nothing if no notifications - def on_datetime_clicked(self, *args): - """Handle datetime button clicks - open todo list.""" - self.todo_list.toggle_mousecapture() - def update_notification_icon(self): - """Update the notification icon based on count and DND state.""" count = notification_service.count dnd_enabled = modus_service.dont_disturb @@ -244,7 +168,71 @@ class Panel(Window): # No notifications - show inactive icon icon_file = "notification-inactive.svg" - icon_path = get_relative_path( - f"../../config/assets/icons/notifications/{icon_file}" - ) - self.notification_icon.set_from_file(icon_path) + self.notification_icon.dynamic_file(f"notifications/{icon_file}") + + def _rebuild_layout_from_config(self): + # Left + left_children = [] + if get_config("imac_button", True): + left_children.append(self.imac) + if get_config("global_menu", True): + left_children.append(self.globalmenu) + self.left_box.children = left_children + + # Indicators (create fresh instances on each rebuild) + indicators_children = [] + if get_config("battery", True): + indicators_children.append(BatteryIndicator()) + if get_config("network", True): + indicators_children.append(NetworkIndicator()) + if get_config("bluetooth", True): + indicators_children.append(BluetoothIndicator()) + self.indicators.children = indicators_children + + # Right + right_children = [] + if get_config("workspace_indicator", True): + right_children.append(self.workspace_indicator) + + if get_config("systray", True): + right_children.extend([self.tray_revealer, self.chevron_button]) + + right_children.append(self.indicators) + + if get_config("search", True): + right_children.append(self.search) + if get_config("control_center", True): + right_children.append(self.control_center_btn) + if get_config("date_time", True): + right_children.append(self.datetime_btn) + if get_config("notification_center", True): + right_children.append(self.notification_center_btn) + + self.right_box.children = right_children + self.show_all() + + def _on_config_changed(self, new_config, old_config): + keys = { + "imac_button", + "global_menu", + "workspace_indicator", + "systray", + "battery", + "network", + "bluetooth", + "search", + "control_center", + "date_time", + "notification_center", + } + if any(new_config.get(k) != old_config.get(k) for k in keys): + self._rebuild_layout_from_config() + + def toggle_tray(self, *_): + current_state = self.tray_revealer.child_revealed + self.tray_revealer.child_revealed = not current_state + + if self.tray_revealer.child_revealed: + self.chevron_button.get_child().dynamic_file("misc/chevron-left.svg") + else: + self.chevron_button.get_child().dynamic_file("misc/chevron-right.svg")