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:
3 files changed
+373
-128
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user