refactor(src/window/controlcenter): simplify bluetooth status handling and UI updates

Cleanup duplicated UI updates and improve signal handling logic for better stability.
This commit is contained in:
sankalp committed 2026-06-17 05:15:03 +05:45
1 parent 13c3036a62
commit 260d215b6b
2 files changed
+41 -170

No files matched your search

+25 -80
View File
@@ -1,7 +1,6 @@
import subprocess
from enum import Enum, auto
from services.bluetooth import BluetoothClient, BluetoothDevice
from fabric.utils import Gdk, GLib, exec_shell_command, logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
@@ -10,9 +9,10 @@ from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.separator import Separator
from utils.utils import svg_file
from services.bluetooth import BluetoothClient, BluetoothDevice
from shared.widgets.smooth_switch import SmoothSwitch
from shared.window.animated_scrollwindow import AnimatedScrollable
from utils.utils import svg_file
def get_battery_icon_file(
@@ -36,8 +36,6 @@ def get_battery_icon_file(
def set_bluetooth_enabled_with_fallback(client, enabled: bool):
# Always run rfkill first because if it's blocked, BlueZ will fail and
# our custom client catches the exception (so it won't trigger an except block here).
if enabled:
command = "rfkill unblock bluetooth"
else:
@@ -49,9 +47,7 @@ def set_bluetooth_enabled_with_fallback(client, enabled: bool):
elif isinstance(result, str) and result.strip():
logger.warning(f"rfkill command output: {result.strip()}")
# Try setting enabled state via client as well
try:
# Our custom client has enabled as a property, but setting it sets it on adapters
if hasattr(client, "set_enabled"):
client.set_enabled(enabled)
else:
@@ -94,7 +90,6 @@ class BluetoothDeviceSlot(CenterBox):
style_classes=" ".join(self.styles),
)
# Status icon for connection state
self.status_icon = Image(
icon_name="process-working-symbolic",
size=5,
@@ -118,7 +113,6 @@ class BluetoothDeviceSlot(CenterBox):
)
self.start_children = [self.device_button]
# Add battery info if available
if hasattr(device, "battery_percentage") and device.battery_percentage > 0:
battery_box = Box(orientation="h", spacing=4)
@@ -143,7 +137,6 @@ class BluetoothDeviceSlot(CenterBox):
self.device.emit("changed")
def _get_icon_for_device(self, device) -> str:
# Fallback to symbolic icons instead of duotone
dtype = getattr(device, "type", None) or getattr(
device, "icon_name", "bluetooth"
)
@@ -210,7 +203,6 @@ class BluetoothDeviceSlot(CenterBox):
except Exception:
pass
# Update battery info if available
if (
hasattr(self.device, "battery_percentage")
and self.device.battery_percentage > 0
@@ -226,20 +218,18 @@ class BluetoothDeviceSlot(CenterBox):
)
)
# 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
else:
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,
@@ -247,9 +237,8 @@ class BluetoothDeviceSlot(CenterBox):
)
)
# Update battery percentage
battery_label.set_label(f"{self.device.battery_percentage:.0f}%")
elif self.end_children: # Remove battery display if no longer available
elif self.end_children:
self.end_children = []
def _refresh_state(self):
@@ -320,7 +309,6 @@ class BluetoothConnections(Box):
style="color: #fff; font-size: 12px; padding: 5px;",
)
# Create title with optional back button
title_children = []
if show_back_button:
title_children.append(
@@ -344,7 +332,6 @@ class BluetoothConnections(Box):
on_user_toggle=self.handle_user_toggle,
)
# Connect client signals — track IDs for disconnect on destroy
self._client_signal_ids.append(
self.client.connect(
"notify::enabled",
@@ -355,7 +342,6 @@ class BluetoothConnections(Box):
self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
)
# Connect to device changes
self._client_signal_ids.append(
self.client.connect("device-added", self.update_devices)
)
@@ -363,7 +349,6 @@ class BluetoothConnections(Box):
self.client.connect("device-removed", self.update_devices)
)
# Connect to additional signals for better real-time monitoring
self._client_signal_ids.append(
self.client.connect("changed", self.on_client_changed)
)
@@ -382,7 +367,6 @@ class BluetoothConnections(Box):
overlay_scroll=True,
)
# Create "No devices available" message
self.no_devices_label = Label(
label="No devices available",
h_align="center",
@@ -390,7 +374,6 @@ class BluetoothConnections(Box):
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",
@@ -399,7 +382,6 @@ class BluetoothConnections(Box):
self.other_devices = Box(spacing=4, orientation="vertical")
self.other_devices.set_size_request(-1, 150)
# Create scrolled window for other devices
self.other_devices_scrolled = AnimatedScrollable(
min_content_size=(303, 0),
max_content_size=(303, 300),
@@ -411,7 +393,6 @@ class BluetoothConnections(Box):
# Add pull-to-refresh functionality to scrolled window
self.setup_pull_to_refresh()
# 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",
@@ -436,22 +417,18 @@ class BluetoothConnections(Box):
self.more_settings_button,
]
# Connect cleanup on destroy
self.connect("destroy", self.on_destroy)
self.connect("unmap", self.on_hide)
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 handle_user_toggle(self, active: bool):
self._switch_lock = True
set_bluetooth_enabled_with_fallback(self.client, active)
# Lock the switch UI for 1.5 seconds to prevent DBus propagation flicker
GLib.timeout_add(1500, self._unlock_switch)
def _unlock_switch(self):
@@ -460,18 +437,30 @@ class BluetoothConnections(Box):
self.toggle_button.set_active(self.client.enabled)
return False
def on_hide(self, *_):
"""Called when the widget is hidden (popup closed)"""
if self.other_devices.get_visible():
self.other_devices.set_visible(False)
self.other_devices_scrolled.snap_to_size(0)
if (
self.client
and self.client.scanning
and hasattr(self.client, "stop_scan")
):
self.client.stop_scan()
self.update_scan_label()
def toggle_other_devices(self, *_):
"""Toggle the visibility of other devices section"""
current_state = self.other_devices.get_visible()
self.other_devices.set_visible(not current_state)
self.update_scan_label()
# Handle scanning based on section visibility
if self.client:
if self.other_devices.get_visible():
# Start scanning when revealing other devices section
if not self.client.scanning and hasattr(self.client, "scan"):
GLib.timeout_add(100, self.client.scan)
# Also force an immediate device refresh to catch any missed connections
if hasattr(self, "_refresh_timeout_id") and self._refresh_timeout_id:
GLib.source_remove(self._refresh_timeout_id)
@@ -482,7 +471,6 @@ class BluetoothConnections(Box):
self._refresh_timeout_id = GLib.timeout_add(150, _do_refresh)
else:
# Stop scanning when hiding other devices section
if self.client.scanning and hasattr(self.client, "stop_scan"):
self.client.stop_scan()
@@ -499,26 +487,22 @@ class BluetoothConnections(Box):
def update_scan_label(self):
"""Update scanning state appearance"""
if self.client.scanning:
# Show scanning feedback in refresh indicator
if self.client and self.client.scanning and self.other_devices.get_visible():
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"""
# Prevent concurrent updates and check if destroyed
if self._update_in_progress or self._destroyed or not self.client:
return
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()
@@ -530,7 +514,6 @@ class BluetoothConnections(Box):
if hasattr(child, "device")
}
# Get current devices safely
devices = self.client.devices
paired_devices = []
other_devices = []
@@ -540,8 +523,6 @@ class BluetoothConnections(Box):
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)
@@ -551,11 +532,9 @@ class BluetoothConnections(Box):
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:
existing_paired = {
child.device.address: child
@@ -568,7 +547,6 @@ class BluetoothConnections(Box):
if hasattr(child, "device")
}
# Add paired devices
for device in paired_devices:
if not self._destroyed:
if device.address in existing_paired:
@@ -584,7 +562,6 @@ class BluetoothConnections(Box):
device_slot = BluetoothDeviceSlot(device)
self.paired_devices.add(device_slot)
# Add other devices
for device in other_devices:
if not self._destroyed:
if device.address in existing_other:
@@ -599,30 +576,19 @@ class BluetoothConnections(Box):
device_slot = BluetoothDeviceSlot(device)
self.other_devices.add(device_slot)
# We deliberately DO NOT destroy slots that disappear from the current scan.
# Destroying slots causes the window to shrink abruptly, which can cause
# the mouse cursor to fall outside the window bounds, triggering Hyprland
# to instantly dismiss the popup.
# Instead, they remain in the list until the control center is closed.
for slot in existing_paired.values():
pass
for slot in existing_other.values():
pass
# 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
# Always keep scrolled window visible so it animates to 0 smoothly
self.paired_devices_scrolled.set_visible(True)
# 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:
@@ -632,8 +598,6 @@ class BluetoothConnections(Box):
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):
@@ -644,7 +608,6 @@ class BluetoothConnections(Box):
def periodic_device_refresh(self):
"""Periodically refresh device list to catch external connections"""
# If destroyed, remove the GLib source by returning False
if self._destroyed:
self.refresh_timer = None
return False
@@ -658,7 +621,7 @@ class BluetoothConnections(Box):
except Exception:
pass
return True # Continue monitoring
return True
def force_device_refresh(self):
"""Force an immediate refresh of the device list"""
@@ -666,38 +629,28 @@ class BluetoothConnections(Box):
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"""
# Sync switch state and scanning label directly since custom BluetoothClient
# doesn't emit notify::enabled or notify::scanning when adapters update.
if getattr(self, "toggle_button", None) and not self._switch_lock:
self.toggle_button.set_active(self.client.enabled)
self.update_scan_label()
# 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 timer
self.stop_device_monitoring()
# Disconnect all BluetoothClient signals
for sig_id in self._client_signal_ids:
try:
self.client.disconnect(sig_id)
except Exception:
pass
self._client_signal_ids.clear()
# Collapse other devices revealer
try:
self.other_devices_revealer.child_revealed = False
except Exception:
@@ -705,12 +658,10 @@ class BluetoothConnections(Box):
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
@@ -738,15 +689,13 @@ class BluetoothConnections(Box):
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 - force scan and refresh
if not self.client.scanning and hasattr(self.client, "scan"):
self.client.scan()
self.force_device_refresh()
return True # Consume the event
return False # Let normal scrolling continue
return True
return False
def on_button_press(self, widget, event):
"""Handle button press for touch/drag gestures"""
@@ -760,11 +709,9 @@ class BluetoothConnections(Box):
if self.is_pulling:
pull_distance = event.y - self.pull_start_y
if pull_distance > self.pull_threshold:
# Force scan and refresh
if not self.client.scanning and hasattr(self.client, "scan"):
self.client.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
@@ -775,7 +722,6 @@ class BluetoothConnections(Box):
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:
@@ -795,5 +741,4 @@ class BluetoothConnections(Box):
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()
+16 -90
View File
@@ -9,11 +9,11 @@ from fabric.widgets.label import Label
from fabric.widgets.separator import Separator
from services.network import NetworkClient
from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog
from shared.widgets.smooth_switch import SmoothSwitch
from shared.window.animated_scrollwindow import AnimatedScrollable
from utils.functions import get_wifi_connecting_icon, get_wifi_icon_for_strength
from utils.utils import svg_file
from shared.widgets.smooth_switch import SmoothSwitch
from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog
from shared.window.animated_scrollwindow import AnimatedScrollable
class WifiNetworkSlot(Box):
@@ -26,32 +26,24 @@ class WifiNetworkSlot(Box):
self.network_service = network_service
self.parent = parent # Reference to control center
# Get network info from AccessPoint object
self.ssid = access_point.ssid
self.bssid = access_point.bssid
self.strength = access_point.strength
self.icon_name = access_point.icon
# Check if this network is currently connected
self.is_connected = access_point.is_active
# Initialize styles based on connection state
self.styles = [
"connected" if self.is_connected else "",
]
# 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_file(wifi_icon_path, size=28)
self.wifi_icon_box = Box(
children=[self.dimage],
style_classes=["wifi-icon-box"],
)
# if self.is_connected:
# self.wifi_icon_box.remove_style_class("wifi-icon-box")
# self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
#
# Set initial style classes
if self.is_connected:
self.dimage.add_style_class("connected")
@@ -68,10 +60,8 @@ class WifiNetworkSlot(Box):
name="wifi-lock-icon",
)
# Initialize password dialog
self.password_dialog = None
# Create the start section with WiFi icon and network name
start_box = Box(
orientation="h",
spacing=8,
@@ -79,7 +69,6 @@ class WifiNetworkSlot(Box):
start_box.children = [self.wifi_icon_box, self.network_label]
if self.lock_icon:
start_box.children.append(self.lock_icon)
# Create end section with lock icon if needed
self.children = [
Button(
@@ -90,7 +79,6 @@ class WifiNetworkSlot(Box):
)
]
# Emit initial change to update display
self.on_changed()
def update_ap(self, access_point):
@@ -100,7 +88,6 @@ class WifiNetworkSlot(Box):
self.strength = access_point.strength
self.is_connected = access_point.is_active
# Update icon only if not currently animating a connection state
if not self.dimage.has_style_class(
"connecting"
) and not self.dimage.has_style_class("disconnecting"):
@@ -112,16 +99,13 @@ class WifiNetworkSlot(Box):
self.on_changed()
def toggle_connecting(self):
# Check if this network is currently connected
is_currently_connected = self.access_point.is_active
if is_currently_connected:
# Show disconnecting state using connecting icon
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("disconnecting")
# Disconnect from network
if self.wifi_service and self.wifi_service._device:
subprocess.Popen(
[
@@ -132,16 +116,13 @@ class WifiNetworkSlot(Box):
]
)
self.is_connected = False
# Remove disconnecting state after a short delay to show feedback
GLib.timeout_add(500, lambda: self._reset_disconnect_state())
else:
# Try to connect - check if password is required and not saved
is_saved = self.network_service and self.network_service.is_network_saved(
self.ssid
)
if self.access_point.requires_password and not is_saved:
# Show password dialog immediately
self._show_password_dialog()
else:
# Try to connect without password (for open or saved networks)
@@ -171,11 +152,9 @@ class WifiNetworkSlot(Box):
self.access_point.bssid, callback=on_open_connection_result
)
except Exception:
# Handle any connection errors gracefully
self._reset_connect_state()
self.on_changed()
# Update display after connection attempt
self.on_changed()
def _reset_disconnect_state(self):
@@ -184,7 +163,7 @@ class WifiNetworkSlot(Box):
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
return False # Remove timeout
return False
def _reset_connect_state(self):
"""Reset visual state after connect operation"""
@@ -192,15 +171,14 @@ class WifiNetworkSlot(Box):
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
return False # Remove timeout
return False
def on_changed(self, *_):
# Check if this network is currently connected using the access point's is_active property
self.is_connected = self.access_point.is_active
self.styles = [
"connected" if self.is_connected else "",
]
# Update style classes for SVG widget
if self.is_connected:
self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
else:
@@ -209,11 +187,9 @@ class WifiNetworkSlot(Box):
def _show_password_dialog(self):
"""Show the WiFi password dialog"""
# Close the control center first
if self.parent and hasattr(self.parent, "hide_controlcenter"):
self.parent.hide_controlcenter()
# Create a new dialog each time to ensure clean state
if self.password_dialog:
self.password_dialog.destroy_dialog()
@@ -228,21 +204,17 @@ class WifiNetworkSlot(Box):
def _on_password_connect(self, ssid, password):
"""Handle password dialog connect action"""
if password.strip():
# Show connecting state using connecting icon
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("connecting")
# Try to connect with password using callback
def on_connection_result(success, message):
"""Handle the connection result"""
if success:
self.is_connected = True
# Remove connecting state after a short delay
GLib.timeout_add(500, lambda: self._reset_connect_state())
# Clear any timeout in the password dialog
if (
self.password_dialog
and self.password_dialog.connection_timeout_id
@@ -251,12 +223,10 @@ class WifiNetworkSlot(Box):
self.password_dialog.connection_timeout_id = None
self.password_dialog.is_connecting = False
else:
# Connection failed - show error in dialog
self._reset_connect_state()
if self.password_dialog:
self._show_connection_error(message)
# Update display after connection attempt
self.on_changed()
try:
@@ -265,7 +235,6 @@ class WifiNetworkSlot(Box):
self.access_point.bssid, password, callback=on_connection_result
)
except Exception:
# Handle any connection errors gracefully
self._reset_connect_state()
if self.password_dialog:
self._show_connection_error("Connection failed. Please try again.")
@@ -279,7 +248,6 @@ class WifiNetworkSlot(Box):
def _on_password_cancel(self):
"""Handle password dialog cancel action"""
# Reset any connecting state
self._reset_connect_state()
@@ -301,7 +269,6 @@ class WifiConnections(Box):
self._destroyed = False # Track if widget is destroyed
self._signal_ids = [] # Track all service signal IDs for cleanup
# Wait for network service to be ready — track signal ID
self._signal_ids.append(
(
self.network_service,
@@ -318,7 +285,6 @@ class WifiConnections(Box):
style="color: #fff; font-size: 12px; padding: 5px;",
)
# Create title with optional back button
title_children = []
if show_back_button:
title_children.append(
@@ -409,22 +375,25 @@ class WifiConnections(Box):
self.more_settings_button,
]
# Connect cleanup on destroy
self.connect("destroy", self.on_destroy)
self.connect("unmap", self.on_hide)
# Start periodic network monitoring for real-time updates
self.start_network_monitoring()
def on_hide(self, *_):
"""Called when the widget is hidden (popup closed)"""
if self.other_networks.get_visible():
self.other_networks.set_visible(False)
self.other_networks_scrolled.snap_to_size(0)
def toggle_other_networks(self, *_):
"""Toggle the visibility of other networks section"""
current_state = self.other_networks.get_visible()
self.other_networks.set_visible(not current_state)
if self.other_networks.get_visible():
# Trigger a scan when revealing other networks and force refresh
if self.wifi_service:
self.wifi_service.scan()
# Defer network refresh to avoid layout-shift killing the window focus during click
if hasattr(self, "_refresh_timeout_id") and self._refresh_timeout_id:
GLib.source_remove(self._refresh_timeout_id)
@@ -439,10 +408,8 @@ class WifiConnections(Box):
"""Called when network service is ready"""
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
# Set up WiFi toggle
self.toggle_button.set_active(self.wifi_service.enabled)
# Connect to WiFi service signals — track IDs for cleanup
self._signal_ids.append(
(
self.wifi_service,
@@ -492,7 +459,6 @@ class WifiConnections(Box):
self._update_in_progress = True
try:
# Store current network SSIDs to detect changes
current_known_ssids = {
child.ssid
for child in self.known_networks.get_children()
@@ -504,7 +470,6 @@ class WifiConnections(Box):
if hasattr(child, "ssid")
}
# Get current networks
access_points = self.wifi_service.access_points
known_networks = []
other_networks = []
@@ -514,8 +479,6 @@ class WifiConnections(Box):
for access_point in access_points:
try:
if access_point.ssid and access_point.ssid != "Unknown":
# Categorize networks: connected or saved networks go to "Known Network"
# All others go to "Other Networks"
if access_point.is_active or self._is_saved_network(
access_point
):
@@ -527,11 +490,9 @@ class WifiConnections(Box):
except Exception:
continue
# Check if we need to update (networks added/removed)
known_changed = current_known_ssids != new_known_ssids
other_changed = current_other_ssids != new_other_ssids
# Only rebuild if something actually changed
if known_changed or other_changed:
# Get existing networks
existing_known = {
@@ -541,7 +502,6 @@ class WifiConnections(Box):
child.ssid: child for child in self.other_networks.get_children()
}
# Process known networks
for access_point in known_networks:
if not self._destroyed:
if access_point.ssid in existing_known:
@@ -561,7 +521,6 @@ class WifiConnections(Box):
)
self.known_networks.add(network_slot)
# Process other networks
for access_point in other_networks:
if not self._destroyed:
if access_point.ssid in existing_other:
@@ -581,13 +540,7 @@ class WifiConnections(Box):
)
self.other_networks.add(network_slot)
# We deliberately DO NOT destroy slots that disappear from the current scan.
# Destroying slots causes the window to shrink abruptly, which can cause
# the mouse cursor to fall outside the window bounds, triggering Hyprland
# to instantly dismiss the popup.
# Instead, they remain in the list until the control center is closed.
for slot in existing_known.values():
# Update to 0 strength if it's completely gone
if hasattr(slot, "access_point"):
slot.strength = 0
from shared.widgets.wifi_icon import get_wifi_icon_for_strength
@@ -607,22 +560,14 @@ class WifiConnections(Box):
) and not slot.dimage.has_style_class("disconnecting"):
slot.dimage.set_from_file(get_wifi_icon_for_strength(0))
# Show/hide sections based on available networks
if not self._destroyed:
has_known_networks = len(known_networks) > 0
has_other_networks = len(other_networks) > 0
has_any_networks = has_known_networks or has_other_networks
# Always keep scrolled window visible so it animates to 0 smoothly instead of abruptly skipping layout
self.known_networks_scrolled.set_visible(True)
# Show "No networks available" message if no networks at all
self.no_networks_label.set_visible(not has_any_networks)
# Always show the other networks button, regardless of available networks
self.other_networks_button.set_visible(True) # Always visible
# Update all network connection states
self.refresh_network_states()
except Exception:
@@ -641,20 +586,16 @@ class WifiConnections(Box):
def refresh_network_states(self, *_):
"""Refresh connection states for all network slots"""
# Refresh known networks
for child in self.known_networks.get_children():
if hasattr(child, "on_changed"):
child.on_changed()
# Refresh other networks
for child in self.other_networks.get_children():
if hasattr(child, "on_changed"):
child.on_changed()
def start_network_monitoring(self):
"""Start periodic monitoring for network changes"""
# Monitor for network changes every 5 seconds
# This helps catch networks that connect from external sources
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)
def stop_network_monitoring(self):
@@ -665,12 +606,10 @@ class WifiConnections(Box):
def periodic_network_refresh(self):
"""Periodically refresh network list to catch external connections"""
# If destroyed, remove the GLib source by returning False
if self._destroyed:
self.refresh_timer = None
return False
# Skip if update in progress or wifi not available/enabled
if (
self._update_in_progress
or not self.wifi_service
@@ -691,14 +630,12 @@ class WifiConnections(Box):
return
try:
# Simply trigger update_networks which has its own safety checks
self.update_networks()
except Exception:
pass
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_networks_scrolled.get_vadjustment()
# Track gesture state
@@ -726,15 +663,13 @@ class WifiConnections(Box):
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 - trigger scan and force refresh
if self.wifi_service:
self.wifi_service.scan()
self.force_network_refresh()
return True # Consume the event
return False # Let normal scrolling continue
return True
return False
def on_button_press(self, widget, event):
"""Handle button press for touch/drag gestures"""
@@ -748,11 +683,9 @@ class WifiConnections(Box):
if self.is_pulling:
pull_distance = event.y - self.pull_start_y
if pull_distance > self.pull_threshold:
# Trigger scan and force refresh
if self.wifi_service:
self.wifi_service.scan()
self.force_network_refresh()
# Hide refresh indicator
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("ready-to-refresh")
self.is_pulling = False
@@ -763,7 +696,6 @@ class WifiConnections(Box):
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:
self.refresh_indicator.set_label("↑ Release to scan")
@@ -777,21 +709,16 @@ class WifiConnections(Box):
def on_destroy(self, widget):
"""Cleanup when widget is destroyed"""
# Mark as destroyed to prevent further updates
self._destroyed = True
# Stop monitoring timer
self.stop_network_monitoring()
# Disconnect all tracked service signals
for obj, sig_id in self._signal_ids:
try:
obj.disconnect(sig_id)
except Exception:
pass
self._signal_ids.clear()
# Drop strong references
self.wifi_service = None
self.network_service = None
# Collapse other networks revealer
try:
self.other_networks_revealer.child_revealed = False
except Exception:
@@ -799,5 +726,4 @@ class WifiConnections(Box):
def close_wifi(self):
"""Called when WiFi panel is being closed"""
# Collapse the other networks section when closing
self.other_networks_revealer.child_revealed = False