feat(src/window/controlcenter): enhance bluetooth handle with fallback and enum

Introduce BTState enum, use rfkill fallback for enable/disable, and preserve network slots to avoid UI jitter.
This commit is contained in:
sankalp committed 2026-06-17 04:52:44 +05:45
1 parent 929e525589
commit a382f73795
3 files changed
+373 -128

No files matched your search

+225 -74
View File
@@ -1,18 +1,18 @@
import subprocess
from fabric.bluetooth import BluetoothClient, BluetoothDevice
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
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 utils.utils import svg_file
from shared.widgets.smooth_switch import SmoothSwitch
from shared.window.animated_scrollwindow import AnimatedScrollable
def get_battery_icon_file(
@@ -36,26 +36,35 @@ 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:
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():
logger.warning(f"rfkill command output: {result.strip()}")
# Try setting enabled state via client as well
try:
# Try fabric bluetooth first
client.set_enabled(enabled)
# 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:
client.enabled = enabled
except Exception as e:
logger.warning(f"Fabric bluetooth set_enabled({enabled}) failed: {e}")
logger.warning(f"Bluetooth set_enabled({enabled}) failed: {e}")
# Fallback to rfkill to unblock/block bluetooth
if enabled:
command = "rfkill unblock bluetooth"
else:
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 BTState(Enum):
IDLE = auto()
CONNECTED = auto()
CONNECTING = auto()
FAILED = auto()
class BluetoothDeviceSlot(CenterBox):
@@ -63,11 +72,13 @@ class BluetoothDeviceSlot(CenterBox):
super().__init__(h_expand=True, name="device-button", **kwargs)
self.device = device
self._destroyed = False
self._state = BTState.IDLE
self._signal_ids = []
self._signal_ids.append(self.device.connect("changed", self.on_changed))
self._signal_ids.append(
self.device.connect(
"notify::closed", lambda *_: self.device.closed and self.destroy()
"notify::closed",
lambda *_: getattr(self.device, "closed", False) and self.destroy(),
)
)
@@ -77,35 +88,47 @@ class BluetoothDeviceSlot(CenterBox):
]
self.dimage = Image(
icon_name=device.icon_name + "-symbolic", # type: ignore
icon_name=self._get_icon_for_device(self.device),
size=5,
name="device-icon",
style_classes=" ".join(self.styles),
)
# Status icon for connection state
self.status_icon = Image(
icon_name="process-working-symbolic",
size=5,
name="device-icon",
)
self.status_icon.set_visible(False)
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
spacing=8,
children=[
self.dimage,
Label(label=device.name),
Box(h_expand=True), # Spacer
self.status_icon,
],
),
)
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_file(
get_battery_icon_file(
device.battery_percentage,
False, # Not charging for bluetooth devices
False,
)
)
# Create battery percentage label
battery_label = Label(
label=f"{device.battery_percentage:.0f}%", name="battery-label"
)
@@ -115,7 +138,25 @@ class BluetoothDeviceSlot(CenterBox):
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
self._refresh_state()
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"
)
return {
"Headset": "audio-headset-symbolic",
"Headphones": "audio-headphones-symbolic",
"Speaker": "audio-speakers-symbolic",
"Keyboard": "input-keyboard-symbolic",
"Mouse": "input-mouse-symbolic",
"Joypad": "input-gaming-symbolic",
"Phone": "phone-symbolic",
"Printer": "printer-symbolic",
}.get(dtype, dtype + "-symbolic" if not dtype.endswith("-symbolic") else dtype)
def destroy(self):
"""Clean up device signal connections to prevent memory leaks."""
@@ -138,23 +179,36 @@ class BluetoothDeviceSlot(CenterBox):
self.remove_style_class("button-hovered")
def toggle_connecting(self):
if self._destroyed:
return
if self._state == BTState.CONNECTED:
self.device.connecting = False
self._set_state(BTState.IDLE)
elif self._state != BTState.CONNECTING:
self._set_state(BTState.CONNECTING)
self.device.connecting = True
self.device.emit("changed")
self.device.set_connecting(not self.device.connected)
def on_changed(self, *_):
if self._destroyed or self.device is None:
return
if getattr(self.device, "connected", False):
self._set_state(BTState.CONNECTED)
elif self._state == BTState.CONNECTING:
self._set_state(BTState.FAILED)
else:
self._set_state(BTState.IDLE)
try:
# Update connection and pairing status
new_styles = [
"connected" if getattr(self.device, "connected", False) else "",
"paired" if getattr(self.device, "paired", False) else "",
]
self.styles = new_styles
self.dimage.set_property("style-classes", " ".join(self.styles))
except Exception:
return
pass
# Update battery info if available
if (
@@ -198,6 +252,41 @@ class BluetoothDeviceSlot(CenterBox):
elif self.end_children: # Remove battery display if no longer available
self.end_children = []
def _refresh_state(self):
self._set_state(
BTState.CONNECTED
if getattr(self.device, "connected", False)
else BTState.IDLE
)
def _set_state(self, state: BTState):
if self._destroyed:
return
self._state = state
for cls in ["active", "connecting", "failed"]:
self.remove_style_class(cls)
if state == BTState.CONNECTED:
self.add_style_class("active")
self.status_icon.set_visible(True)
self.status_icon.set_from_icon_name("emblem-ok-symbolic", 5)
elif state == BTState.CONNECTING:
self.add_style_class("connecting")
self.status_icon.set_visible(True)
self.status_icon.set_from_icon_name("process-working-symbolic", 5)
elif state == BTState.FAILED:
self.add_style_class("failed")
self.status_icon.set_visible(True)
self.status_icon.set_from_icon_name("dialog-error-symbolic", 5)
GLib.timeout_add(5000, self._reset_from_failed)
else:
self.status_icon.set_visible(False)
def _reset_from_failed(self):
if not self._destroyed:
self._set_state(BTState.IDLE)
return False
return
@@ -220,7 +309,7 @@ class BluetoothConnections(Box):
self._destroyed = False # Track if widget is destroyed
self._client_signal_ids = [] # Track BluetoothClient signal IDs
self.client = BluetoothClient(on_device_added=self.on_device_added)
self.client = BluetoothClient()
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
@@ -247,12 +336,12 @@ class BluetoothConnections(Box):
children=title_children,
)
self._switch_lock = False
self.toggle_button = SmoothSwitch(
name="toggle-button",
active=self.client.enabled,
on_user_toggle=lambda active: set_bluetooth_enabled_with_fallback(
self.client, active
),
on_user_toggle=self.handle_user_toggle,
)
# Connect client signals — track IDs for disconnect on destroy
@@ -286,6 +375,12 @@ class BluetoothConnections(Box):
self.paired_devices = Box(
spacing=4, orientation="vertical", name="known-networks"
)
self.paired_devices_scrolled = AnimatedScrollable(
min_content_size=(303, 0),
max_content_size=(303, 200),
child=self.paired_devices,
overlay_scroll=True,
)
# Create "No devices available" message
self.no_devices_label = Label(
@@ -302,25 +397,20 @@ class BluetoothConnections(Box):
on_clicked=self.toggle_other_devices,
)
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 = ScrolledWindow(
min_content_size=(303, 150),
self.other_devices_scrolled = AnimatedScrollable(
min_content_size=(303, 0),
max_content_size=(303, 300),
child=self.other_devices,
overlay_scroll=True,
)
self.other_devices.set_visible(False)
# 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"),
@@ -337,11 +427,11 @@ class BluetoothConnections(Box):
self.refresh_indicator,
Separator(orientation="h", name="separator"),
self.paired_devices_label,
self.paired_devices,
self.paired_devices_scrolled,
self.no_devices_label,
Separator(orientation="h", name="separator"),
self.other_devices_button,
self.other_devices_revealer,
self.other_devices_scrolled,
Separator(orientation="h", name="separator"),
self.more_settings_button,
]
@@ -358,23 +448,43 @@ class BluetoothConnections(Box):
# 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):
self._switch_lock = False
if not self._destroyed and getattr(self, "toggle_button", None):
self.toggle_button.set_active(self.client.enabled)
return False
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
current_state = self.other_devices.get_visible()
self.other_devices.set_visible(not current_state)
# Handle scanning based on section visibility
if self.client:
if self.other_devices_revealer.child_revealed:
if self.other_devices.get_visible():
# Start scanning when revealing other devices section
if not self.client.scanning:
self.client.toggle_scan()
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
self.force_device_refresh()
if hasattr(self, "_refresh_timeout_id") and self._refresh_timeout_id:
GLib.source_remove(self._refresh_timeout_id)
def _do_refresh():
self.force_device_refresh()
self._refresh_timeout_id = None
return False
self._refresh_timeout_id = GLib.timeout_add(150, _do_refresh)
else:
# Stop scanning when hiding other devices section
if self.client.scanning:
self.client.toggle_scan()
if self.client.scanning and hasattr(self.client, "stop_scan"):
self.client.stop_scan()
def open_bluetooth_settings(self, *_):
"""Open Blueman bluetooth manager"""
@@ -447,25 +557,58 @@ class BluetoothConnections(Box):
# 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()
existing_paired = {
child.device.address: child
for child in self.paired_devices.get_children()
if hasattr(child, "device")
}
existing_other = {
child.device.address: child
for child in self.other_devices.get_children()
if hasattr(child, "device")
}
# Add paired devices
for device in paired_devices:
if not self._destroyed:
device_slot = BluetoothDeviceSlot(device)
self.paired_devices.add(device_slot)
if device.address in existing_paired:
slot = existing_paired.pop(device.address)
# update existing
slot.device = device
elif device.address in existing_other:
slot = existing_other.pop(device.address)
slot.device = device
self.other_devices.remove(slot)
self.paired_devices.add(slot)
else:
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)
if device.address in existing_other:
slot = existing_other.pop(device.address)
slot.device = device
elif device.address in existing_paired:
slot = existing_paired.pop(device.address)
slot.device = device
self.paired_devices.remove(slot)
self.other_devices.add(slot)
else:
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:
@@ -473,9 +616,8 @@ class BluetoothConnections(Box):
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)
# 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)
@@ -532,6 +674,13 @@ class BluetoothConnections(Box):
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()
@@ -592,8 +741,9 @@ class BluetoothConnections(Box):
# 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()
# 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
@@ -610,8 +760,9 @@ class BluetoothConnections(Box):
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()
# 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)
+21 -2
View File
@@ -5,6 +5,7 @@ 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.eventbox import EventBox
from shared.widgets.flat_scale import FlatScale
from services.brightness import Brightness
@@ -637,12 +638,17 @@ class ModusControlCenter(AppletWindow):
def _ensure_bluetooth_widgets(self):
"""Lazy load bluetooth widgets"""
if self.bluetooth_widgets is None:
self.bluetooth_widgets = Box(
inner_box = Box(
orientation="vertical",
h_expand=True,
v_expand=True,
children=[self.bluetooth_man],
)
self.bluetooth_widgets = EventBox(
events="button-press-mask",
on_button_press_event=lambda *_: True,
child=inner_box,
)
self.bluetooth_center_box = Box(
h_expand=True, v_expand=True, children=[self.bluetooth_widgets]
)
@@ -651,7 +657,7 @@ class ModusControlCenter(AppletWindow):
def _ensure_wifi_widgets(self):
"""Lazy load wifi widgets"""
if self.wifi_widgets is None:
self.wifi_widgets = Box(
inner_box = Box(
orientation="vertical",
h_expand=True,
v_expand=True,
@@ -659,6 +665,11 @@ class ModusControlCenter(AppletWindow):
self.wifi_man,
],
)
self.wifi_widgets = EventBox(
events="button-press-mask",
on_button_press_event=lambda *_: True,
child=inner_box,
)
self.wifi_center_box = Box(
h_expand=True, v_expand=True, children=[self.wifi_widgets]
)
@@ -891,6 +902,10 @@ class ModusControlCenter(AppletWindow):
self.has_wifi_open = True
def close_bluetooth(self, *_):
try:
self.bluetooth_man.close_bluetooth()
except Exception:
pass
if self.current_view == "expanded_player":
self._crossfade_to_view("main")
else:
@@ -898,6 +913,10 @@ class ModusControlCenter(AppletWindow):
self.has_bluetooth_open = False
def close_wifi(self, *_):
try:
self.wifi_man.close_wifi()
except Exception:
pass
if self.current_view == "expanded_player":
self._crossfade_to_view("main")
else:
+127 -52
View File
@@ -6,8 +6,6 @@ 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
@@ -15,6 +13,7 @@ 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):
@@ -94,6 +93,24 @@ class WifiNetworkSlot(Box):
# Emit initial change to update display
self.on_changed()
def update_ap(self, access_point):
"""Update existing slot with new AP data without recreating it"""
self.access_point = access_point
self.bssid = access_point.bssid
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"):
from shared.widgets.wifi_icon import get_wifi_icon_for_strength
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
def toggle_connecting(self):
# Check if this network is currently connected
is_currently_connected = self.access_point.is_active
@@ -118,12 +135,16 @@ class WifiNetworkSlot(Box):
# 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
if self.access_point.requires_password:
# 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 networks)
# Try to connect without password (for open or saved networks)
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("connecting")
@@ -137,15 +158,18 @@ class WifiNetworkSlot(Box):
else:
# Connection failed
self._reset_connect_state()
# If password was wrong or auth failed, prompt the user
if message == "Wrong password":
self._show_password_dialog()
# Update display after connection attempt
self.on_changed()
try:
if self.network_service:
self.network_service.connect_wifi_bssid(self.access_point.bssid)
GLib.timeout_add(1500, lambda: self._reset_connect_state())
GLib.timeout_add(1500, lambda: self.on_changed())
self.network_service.connect_wifi_bssid(
self.access_point.bssid, callback=on_open_connection_result
)
except Exception:
# Handle any connection errors gracefully
self._reset_connect_state()
@@ -324,6 +348,12 @@ class WifiConnections(Box):
self.known_networks = Box(
spacing=4, orientation="vertical", name="known-networks"
)
self.known_networks_scrolled = AnimatedScrollable(
min_content_size=(303, 0),
max_content_size=(303, 200),
child=self.known_networks,
overlay_scroll=True,
)
# Create "No networks available" message
self.no_networks_label = Label(
@@ -340,25 +370,20 @@ class WifiConnections(Box):
on_clicked=self.toggle_other_networks,
)
self.other_networks = Box(spacing=4, orientation="vertical")
self.other_networks.set_size_request(-1, 150)
# Create scrolled window for other networks
self.other_networks_scrolled = ScrolledWindow(
min_content_size=(303, 150),
self.other_networks_scrolled = AnimatedScrollable(
min_content_size=(303, 0),
max_content_size=(303, 300),
child=self.other_networks,
overlay_scroll=True,
)
self.other_networks.set_visible(False)
# Add pull-to-refresh functionality to scrolled window
self.setup_pull_to_refresh()
# Create revealer for Other Networks section
self.other_networks_revealer = Revealer(
child=self.other_networks_scrolled,
transition_type="slide-down",
transition_duration=100,
child_revealed=False,
)
# Create More Settings button (same style as Other Networks button)
self.more_settings_button = Button(
child=Label("More Settings", h_align="start"),
@@ -375,11 +400,11 @@ class WifiConnections(Box):
self.refresh_indicator,
Separator(orientation="h", name="separator"),
self.known_networks_label,
self.known_networks,
self.known_networks_scrolled,
self.no_networks_label,
Separator(orientation="h", name="separator"),
self.other_networks_button,
self.other_networks_revealer,
self.other_networks_scrolled,
Separator(orientation="h", name="separator"),
self.more_settings_button,
]
@@ -392,16 +417,23 @@ class WifiConnections(Box):
def toggle_other_networks(self, *_):
"""Toggle the visibility of other networks section"""
current_state = self.other_networks_revealer.child_revealed
self.other_networks_revealer.child_revealed = not current_state
current_state = self.other_networks.get_visible()
self.other_networks.set_visible(not current_state)
# Update button text based on state
if self.other_networks_revealer.child_revealed:
if self.other_networks.get_visible():
# Trigger a scan when revealing other networks and force refresh
if self.wifi_service:
self.wifi_service.scan()
# Also force an immediate network refresh to catch any missed connections
self.force_network_refresh()
# 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)
def _do_refresh():
self.force_network_refresh()
self._refresh_timeout_id = None
return False
self._refresh_timeout_id = GLib.timeout_add(150, _do_refresh)
def on_network_ready(self, *_):
"""Called when network service is ready"""
@@ -501,35 +533,79 @@ class WifiConnections(Box):
# Only rebuild if something actually changed
if known_changed or other_changed:
# Clear existing networks safely
for child in list(self.known_networks.get_children()):
if not self._destroyed:
child.destroy()
for child in list(self.other_networks.get_children()):
if not self._destroyed:
child.destroy()
# Get existing networks
existing_known = {
child.ssid: child for child in self.known_networks.get_children()
}
existing_other = {
child.ssid: child for child in self.other_networks.get_children()
}
# Add known networks
# Process known networks
for access_point in known_networks:
if not self._destroyed:
network_slot = WifiNetworkSlot(
access_point,
self.wifi_service,
network_service=self.network_service,
parent=self.parent,
)
self.known_networks.add(network_slot)
if access_point.ssid in existing_known:
slot = existing_known.pop(access_point.ssid)
slot.update_ap(access_point)
elif access_point.ssid in existing_other:
slot = existing_other.pop(access_point.ssid)
slot.update_ap(access_point)
self.other_networks.remove(slot)
self.known_networks.add(slot)
else:
network_slot = WifiNetworkSlot(
access_point,
self.wifi_service,
network_service=self.network_service,
parent=self.parent,
)
self.known_networks.add(network_slot)
# Add other networks
# Process other networks
for access_point in other_networks:
if not self._destroyed:
network_slot = WifiNetworkSlot(
access_point,
self.wifi_service,
network_service=self.network_service,
parent=self.parent,
)
self.other_networks.add(network_slot)
if access_point.ssid in existing_other:
slot = existing_other.pop(access_point.ssid)
slot.update_ap(access_point)
elif access_point.ssid in existing_known:
slot = existing_known.pop(access_point.ssid)
slot.update_ap(access_point)
self.known_networks.remove(slot)
self.other_networks.add(slot)
else:
network_slot = WifiNetworkSlot(
access_point,
self.wifi_service,
network_service=self.network_service,
parent=self.parent,
)
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
if not slot.dimage.has_style_class(
"connecting"
) and not slot.dimage.has_style_class("disconnecting"):
slot.dimage.set_from_file(get_wifi_icon_for_strength(0))
for slot in existing_other.values():
if hasattr(slot, "access_point"):
slot.strength = 0
from shared.widgets.wifi_icon import get_wifi_icon_for_strength
if not slot.dimage.has_style_class(
"connecting"
) 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:
@@ -537,9 +613,8 @@ class WifiConnections(Box):
has_other_networks = len(other_networks) > 0
has_any_networks = has_known_networks or has_other_networks
# Show known networks section only if there are known networks
self.known_networks_label.set_visible(has_known_networks)
self.known_networks.set_visible(has_known_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)