from loguru import logger from services.battery import Battery from fabric.widgets.revealer import Revealer from fabric.widgets.separator import Separator from fabric.widgets.svg import Svg from fabric.widgets.scrolledwindow import ScrolledWindow 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.utils import get_relative_path from fabric.bluetooth import BluetoothClient, BluetoothDevice import subprocess from gi.repository import Gdk, GLib, Gtk import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") class BluetoothDeviceSlot(CenterBox): def __init__(self, device: BluetoothDevice, **kwargs): super().__init__(**kwargs) self.device = device self.device.connect("changed", self.on_changed) self.device.connect( "notify::closed", lambda *_: self.device.closed and self.destroy() ) 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), ) self.start_children = [ Button( image=self.dimage, on_clicked=lambda *_: self.toggle_connecting(), ), Label(label=device.name), # type: ignore ] # 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", ) # Create battery percentage label battery_label = Label( label=f"{device.battery_percentage:.0f}%", name="battery-label" ) battery_box.children = [battery_icon, battery_label] self.end_children = [battery_box] self.device.emit("changed") # to update display status def toggle_connecting(self): self.device.emit("changed") self.device.set_connecting(not self.device.connected) def on_changed(self, *_): self.styles = [ "connected" if self.device.connected else "", "paired" if self.device.paired else "", ] self.dimage.set_property("style-classes", " ".join(self.styles)) # 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( 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", ) # 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.set_from_file( get_relative_path( Battery.get_battery_icon_file( self.device.battery_percentage, False, # Not charging for bluetooth devices "../../config/assets/icons/", ) ) ) # 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 BluetoothConnections(Box): def __init__( self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs ): super().__init__( spacing=8, orientation="vertical", style="margin: 8px", name="bluetooth-connections", **kwargs, ) self.parent = parent self.show_hidden_devices = show_hidden_devices self.is_scanning = False # Track scanning state 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;", ) # 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 *_: self.client.set_enabled(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) # 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() 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 # Update button text based on state if self.other_devices_revealer.child_revealed: # Trigger a scan when revealing other devices if self.client: self.client.start_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: logger.error( "[Bluetooth] blueman-manager not found. Please install blueman package." ) except Exception as e: logger.error(f"[Bluetooth] Failed to open bluetooth settings: {e}") 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: # Hide scanning feedback self.refresh_indicator.set_visible(False) self.refresh_indicator.remove_style_class("scanning") def update_devices(self, *_): """Update the list of available devices""" if not self.client: return # Clear existing devices for child in self.paired_devices.get_children(): child.destroy() for child in self.other_devices.get_children(): child.destroy() # Get current devices devices = self.client.devices paired_devices = [] other_devices = [] for device in devices: 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) else: other_devices.append(device) # Add paired devices for device in paired_devices: device_slot = BluetoothDeviceSlot(device) self.paired_devices.add(device_slot) # Add other devices for device in other_devices: device_slot = BluetoothDeviceSlot(device) self.other_devices.add(device_slot) # Show/hide sections based on available devices 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 def on_destroy(self, widget): """Cleanup when widget is destroyed""" # Make sure other devices revealer is collapsed when closing self.other_devices_revealer.child_revealed = False 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 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()