✨ feat(controlcenter): redesign Wi-Fi and Bluetooth panels with macOS-style UI
This commit is contained in:
1 parent
d30eb019f0
commit
9a20337ced
3 files changed
+462
-391
No files matched your search
+205
-151
@@ -1,19 +1,22 @@
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('Gdk', '3.0')
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
from fabric.bluetooth import BluetoothClient, BluetoothDevice
|
||||
from fabric.utils import get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from fabric.widgets.svg import Svg
|
||||
|
||||
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):
|
||||
@@ -46,28 +49,27 @@ class BluetoothDeviceSlot(CenterBox):
|
||||
]
|
||||
|
||||
# Add battery info if available
|
||||
if hasattr(device, 'battery_percentage') and device.battery_percentage > 0:
|
||||
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,
|
||||
device.battery_percentage,
|
||||
False, # Not charging for bluetooth devices
|
||||
"../../config/assets/icons/"
|
||||
"../../config/assets/icons/",
|
||||
)
|
||||
),
|
||||
name="battery-icon"
|
||||
name="battery-icon",
|
||||
)
|
||||
|
||||
|
||||
# Create battery percentage label
|
||||
battery_label = Label(
|
||||
label=f"{device.battery_percentage:.0f}%",
|
||||
name="battery-label"
|
||||
label=f"{device.battery_percentage:.0f}%", name="battery-label"
|
||||
)
|
||||
|
||||
|
||||
battery_box.children = [battery_icon, battery_label]
|
||||
self.end_children = [battery_box]
|
||||
|
||||
@@ -83,53 +85,57 @@ class BluetoothDeviceSlot(CenterBox):
|
||||
"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 (
|
||||
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,
|
||||
self.device.battery_percentage,
|
||||
False, # Not charging for bluetooth devices
|
||||
"../../config/assets/icons/"
|
||||
"../../config/assets/icons/",
|
||||
)
|
||||
),
|
||||
name="battery-icon"
|
||||
name="battery-icon",
|
||||
)
|
||||
|
||||
|
||||
# Create battery percentage label
|
||||
battery_label = Label(
|
||||
label=f"{self.device.battery_percentage:.0f}%",
|
||||
name="battery-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:
|
||||
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/"
|
||||
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
|
||||
|
||||
|
||||
@@ -138,17 +144,28 @@ class BluetoothConnections(Box):
|
||||
self, parent, show_hidden_devices: bool = False, show_back_button=True, **kwargs
|
||||
):
|
||||
super().__init__(
|
||||
spacing=4,
|
||||
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:
|
||||
@@ -158,7 +175,7 @@ class BluetoothConnections(Box):
|
||||
on_clicked=lambda *_: self.parent.close_bluetooth(),
|
||||
)
|
||||
)
|
||||
title_children.append(Label("Bluetooth"))
|
||||
title_children.append(Label("Bluetooth", name="bluetooth-title"))
|
||||
|
||||
self.title = Box(
|
||||
orientation="h",
|
||||
@@ -179,125 +196,111 @@ class BluetoothConnections(Box):
|
||||
"notify::enabled",
|
||||
lambda *_: self.toggle_button.set_active(self.client.enabled),
|
||||
)
|
||||
# Connect scanning state changes to update scan button
|
||||
self.client.connect("notify::scanning", lambda *_: self.update_scan_label())
|
||||
|
||||
self.not_paired = Box(spacing=2, orientation="vertical")
|
||||
self.paired = Box(spacing=2, orientation="vertical")
|
||||
# Connect to device changes
|
||||
self.client.connect("device-added", self.update_devices)
|
||||
self.client.connect("device-removed", self.update_devices)
|
||||
|
||||
# Create pull-to-refresh indicator
|
||||
self.refresh_indicator = Label(
|
||||
name="bluetooth-refresh-indicator",
|
||||
label="↓ Pull to scan for 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,
|
||||
)
|
||||
|
||||
self.device_box = Box(
|
||||
spacing=2,
|
||||
orientation="vertical",
|
||||
children=[self.refresh_indicator, self.paired, self.not_paired],
|
||||
# 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 with pull-to-refresh
|
||||
self.scrolled_window = ScrolledWindow(
|
||||
min_content_size=(303, 400),
|
||||
max_content_size=(303, 800),
|
||||
child=self.device_box,
|
||||
# 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
|
||||
# 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",
|
||||
),
|
||||
Label("Devices", h_align="start", name="devices-title"),
|
||||
self.scrolled_window,
|
||||
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")
|
||||
|
||||
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.scrolled_window.get_vadjustment()
|
||||
# Initial device update
|
||||
self.update_devices()
|
||||
|
||||
# Track gesture state
|
||||
self.pull_start_y = 0
|
||||
self.is_pulling = False
|
||||
self.pull_threshold = 50 # pixels to trigger refresh
|
||||
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
|
||||
|
||||
# Connect to scroll events
|
||||
self.scrolled_window.connect("scroll-event", self.on_scroll_event)
|
||||
self.scrolled_window.connect("button-press-event", self.on_button_press)
|
||||
self.scrolled_window.connect("button-release-event", self.on_button_release)
|
||||
self.scrolled_window.connect("motion-notify-event", self.on_motion_notify)
|
||||
# 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()
|
||||
|
||||
# Enable events
|
||||
self.scrolled_window.set_events(
|
||||
Gdk.EventMask.SCROLL_MASK
|
||||
| Gdk.EventMask.BUTTON_PRESS_MASK
|
||||
| Gdk.EventMask.BUTTON_RELEASE_MASK
|
||||
| Gdk.EventMask.POINTER_MOTION_MASK
|
||||
)
|
||||
|
||||
def on_scroll_event(self, widget, event):
|
||||
"""Handle scroll events for pull-to-refresh"""
|
||||
# Only handle pull-to-refresh when at the top
|
||||
if self.vadjustment.get_value() <= 0:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
# Scrolling up at the top - toggle scan (start or stop)
|
||||
self.client.toggle_scan()
|
||||
return True # Consume the event
|
||||
return False # Let normal scrolling continue
|
||||
|
||||
def on_button_press(self, widget, event):
|
||||
"""Handle button press for touch/drag gestures"""
|
||||
if self.vadjustment.get_value() <= 0:
|
||||
self.pull_start_y = event.y
|
||||
self.is_pulling = True
|
||||
return False
|
||||
|
||||
def on_button_release(self, widget, event):
|
||||
"""Handle button release for touch/drag gestures"""
|
||||
if self.is_pulling:
|
||||
pull_distance = event.y - self.pull_start_y
|
||||
if pull_distance > self.pull_threshold:
|
||||
# Toggle scan (start or stop)
|
||||
self.client.toggle_scan()
|
||||
# Hide refresh indicator
|
||||
self.refresh_indicator.set_visible(False)
|
||||
self.refresh_indicator.remove_style_class("ready-to-refresh")
|
||||
self.is_pulling = False
|
||||
return False
|
||||
|
||||
def on_motion_notify(self, widget, event):
|
||||
"""Handle motion events for visual feedback during pull"""
|
||||
if self.is_pulling and self.vadjustment.get_value() <= 0:
|
||||
pull_distance = event.y - self.pull_start_y
|
||||
if pull_distance > 0:
|
||||
# Show refresh indicator when pulling down
|
||||
self.refresh_indicator.set_visible(True)
|
||||
if pull_distance >= self.pull_threshold:
|
||||
if self.client.scanning:
|
||||
self.refresh_indicator.set_label("↑ Release to stop scanning")
|
||||
else:
|
||||
self.refresh_indicator.set_label("↑ Release to scan")
|
||||
self.refresh_indicator.add_style_class("ready-to-refresh")
|
||||
else:
|
||||
if self.client.scanning:
|
||||
self.refresh_indicator.set_label("↓ Pull to stop scanning")
|
||||
else:
|
||||
self.refresh_indicator.set_label("↓ Pull to scan for devices")
|
||||
self.refresh_indicator.remove_style_class("ready-to-refresh")
|
||||
else:
|
||||
self.refresh_indicator.set_visible(False)
|
||||
return False
|
||||
def 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"""
|
||||
@@ -308,19 +311,70 @@ class BluetoothConnections(Box):
|
||||
self.refresh_indicator.add_style_class("scanning")
|
||||
else:
|
||||
# Hide scanning feedback
|
||||
if not self.is_pulling:
|
||||
self.refresh_indicator.set_visible(False)
|
||||
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):
|
||||
if not (device := client.get_device(address)):
|
||||
return
|
||||
|
||||
if (device.name in ["", None]) and not self.show_hidden_devices:
|
||||
return
|
||||
|
||||
slot = BluetoothDeviceSlot(device, paired=device.paired)
|
||||
|
||||
if device.paired:
|
||||
return self.paired.add(slot)
|
||||
return self.not_paired.add(slot)
|
||||
"""Handle when a new device is added"""
|
||||
# Update the device list when devices are added
|
||||
self.update_devices()
|
||||
+187
-236
@@ -1,22 +1,25 @@
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('Gdk', '3.0')
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
from loguru import 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.scrolledwindow import ScrolledWindow
|
||||
from services.network import NetworkClient
|
||||
from widgets.wifi_password_dialog import WiFiPasswordDialog
|
||||
from services.network import NetworkClient
|
||||
from fabric.widgets.scrolledwindow import ScrolledWindow
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.box import Box
|
||||
from loguru import logger
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
from fabric.widgets.separator import Separator
|
||||
import gi
|
||||
import subprocess
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
gi.require_version("Gdk", "3.0")
|
||||
|
||||
|
||||
class WifiNetworkSlot(CenterBox):
|
||||
def __init__(self, access_point, wifi_service, parent=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
super().__init__(name="wifi-network-slot", **kwargs)
|
||||
self.access_point = access_point
|
||||
self.wifi_service = wifi_service
|
||||
self.parent = parent # Reference to control center
|
||||
@@ -38,27 +41,52 @@ class WifiNetworkSlot(CenterBox):
|
||||
# Create connection status indicator using symbolic WiFi icon
|
||||
self.dimage = Image(
|
||||
icon_name="network-wireless-symbolic",
|
||||
size=5,
|
||||
size=16,
|
||||
name="device-icon",
|
||||
style_classes=" ".join(self.styles),
|
||||
)
|
||||
|
||||
self.network_label = Label(
|
||||
label=self.ssid, name="wifi-network-name", h_align="start", h_expand=True
|
||||
)
|
||||
|
||||
# Create lock icon for secured networks
|
||||
self.lock_icon = None
|
||||
if self.access_point.requires_password:
|
||||
self.lock_icon = Image(
|
||||
icon_name="changes-prevent-symbolic",
|
||||
size=12,
|
||||
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, children=[self.dimage, self.network_label]
|
||||
)
|
||||
|
||||
# Create end section with lock icon if needed
|
||||
end_children = []
|
||||
if self.lock_icon:
|
||||
end_children.append(self.lock_icon)
|
||||
|
||||
self.start_children = [
|
||||
Button(
|
||||
image=self.dimage,
|
||||
child=start_box,
|
||||
name="wifi-network-button",
|
||||
on_clicked=lambda *_: self.toggle_connecting(),
|
||||
),
|
||||
Label(label=self.ssid),
|
||||
)
|
||||
]
|
||||
|
||||
if end_children:
|
||||
self.end_children = end_children
|
||||
|
||||
# Emit initial change to update display
|
||||
self.on_changed()
|
||||
|
||||
def toggle_connecting(self):
|
||||
"""Toggle WiFi network connection"""
|
||||
# Check if this network is currently connected
|
||||
is_currently_connected = self.access_point.is_active
|
||||
|
||||
@@ -71,8 +99,6 @@ class WifiNetworkSlot(CenterBox):
|
||||
self.wifi_service.disconnect_wifi()
|
||||
self.is_connected = False
|
||||
# Remove disconnecting state after a short delay to show feedback
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.timeout_add(500, lambda: self._reset_disconnect_state())
|
||||
else:
|
||||
# Try to connect - check if password is required
|
||||
@@ -91,8 +117,6 @@ class WifiNetworkSlot(CenterBox):
|
||||
if success:
|
||||
self.is_connected = True
|
||||
# Remove connecting state after a short delay
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.timeout_add(500, lambda: self._reset_connect_state())
|
||||
else:
|
||||
# Connection failed
|
||||
@@ -128,7 +152,6 @@ class WifiNetworkSlot(CenterBox):
|
||||
return False # Remove timeout
|
||||
|
||||
def on_changed(self, *_):
|
||||
"""Update display when connection state changes"""
|
||||
# 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 = [
|
||||
@@ -215,9 +238,10 @@ class WifiNetworkSlot(CenterBox):
|
||||
class WifiConnections(Box):
|
||||
def __init__(self, parent, show_back_button=True, **kwargs):
|
||||
super().__init__(
|
||||
spacing=4,
|
||||
spacing=8,
|
||||
orientation="vertical",
|
||||
style="margin: 8px",
|
||||
name="wifi-connections",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -247,7 +271,7 @@ class WifiConnections(Box):
|
||||
on_clicked=lambda *_: self.parent.close_wifi(),
|
||||
)
|
||||
)
|
||||
title_children.append(Label("WiFi"))
|
||||
title_children.append(Label("Wi-Fi", name="wifi-title"))
|
||||
|
||||
self.title = Box(
|
||||
orientation="h",
|
||||
@@ -256,24 +280,51 @@ class WifiConnections(Box):
|
||||
|
||||
self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
|
||||
|
||||
self.available_networks = Box(spacing=2, orientation="vertical")
|
||||
|
||||
self.device_box = Box(
|
||||
spacing=2,
|
||||
orientation="vertical",
|
||||
children=[self.refresh_indicator, self.available_networks],
|
||||
# Create Known Network section
|
||||
self.known_networks_label = Label(
|
||||
label="Known Network", h_align="start", name="networks-title"
|
||||
)
|
||||
self.known_networks = Box(
|
||||
spacing=4, orientation="vertical", name="known-networks"
|
||||
)
|
||||
|
||||
# Create scrolled window with pull-to-refresh
|
||||
self.scrolled_window = ScrolledWindow(
|
||||
min_content_size=(303, 400),
|
||||
max_content_size=(303, 800),
|
||||
child=self.device_box,
|
||||
# Create "No networks available" message
|
||||
self.no_networks_label = Label(
|
||||
label="No networks available",
|
||||
h_align="center",
|
||||
name="no-networks-label",
|
||||
visible=False,
|
||||
)
|
||||
|
||||
# Create Other Networks section with clickable title
|
||||
self.other_networks_button = Button(
|
||||
child=Label("Other Networks", h_align="start"),
|
||||
name="wifi-other-button",
|
||||
on_clicked=self.toggle_other_networks,
|
||||
)
|
||||
self.other_networks = Box(spacing=4, orientation="vertical")
|
||||
|
||||
# Create scrolled window for other networks
|
||||
self.other_networks_scrolled = ScrolledWindow(
|
||||
min_content_size=(303, 150),
|
||||
child=self.other_networks,
|
||||
overlay_scroll=True,
|
||||
)
|
||||
|
||||
# Add pull-to-refresh functionality
|
||||
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"),
|
||||
name="wifi-other-button",
|
||||
on_clicked=self.open_network_settings,
|
||||
)
|
||||
|
||||
self.children = [
|
||||
CenterBox(
|
||||
@@ -281,13 +332,32 @@ class WifiConnections(Box):
|
||||
end_children=self.toggle_button,
|
||||
name="wifi-widget-top",
|
||||
),
|
||||
Label("Networks", h_align="start", name="networks-title"),
|
||||
self.scrolled_window,
|
||||
self.refresh_indicator,
|
||||
Separator(orientation="h", name="separator"),
|
||||
self.known_networks_label,
|
||||
self.known_networks,
|
||||
self.no_networks_label,
|
||||
Separator(orientation="h", name="separator"),
|
||||
self.other_networks_button,
|
||||
self.other_networks_revealer,
|
||||
Separator(orientation="h", name="separator"),
|
||||
self.more_settings_button,
|
||||
]
|
||||
|
||||
# Connect cleanup on destroy
|
||||
self.connect("destroy", self.on_destroy)
|
||||
|
||||
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
|
||||
|
||||
# Update button text based on state
|
||||
if self.other_networks_revealer.child_revealed:
|
||||
# Trigger a scan when revealing other networks
|
||||
if self.wifi_service:
|
||||
self.wifi_service.scan()
|
||||
|
||||
def on_network_ready(self, *_):
|
||||
"""Called when network service is ready"""
|
||||
self.wifi_service = self.network_service.wifi_device
|
||||
@@ -319,220 +389,101 @@ class WifiConnections(Box):
|
||||
if self.wifi_service:
|
||||
self.toggle_button.set_active(self.wifi_service.wireless_enabled)
|
||||
|
||||
def open_network_settings(self, *_):
|
||||
"""Open NetworkManager connection editor"""
|
||||
try:
|
||||
subprocess.Popen(["nm-connection-editor"], start_new_session=True)
|
||||
if self.parent and hasattr(self.parent, "hide_controlcenter"):
|
||||
self.parent.hide_controlcenter()
|
||||
except FileNotFoundError:
|
||||
logger.error(
|
||||
"[WiFi] nm-connection-editor not found. Please install network-manager-gnome package."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[WiFi] Failed to open network settings: {e}")
|
||||
|
||||
def update_networks(self, *_):
|
||||
"""Update the list of available networks"""
|
||||
if not self.wifi_service:
|
||||
return
|
||||
|
||||
# Clear existing networks
|
||||
for child in self.available_networks.get_children():
|
||||
for child in self.known_networks.get_children():
|
||||
child.destroy()
|
||||
for child in self.other_networks.get_children():
|
||||
child.destroy()
|
||||
|
||||
# Add current networks
|
||||
# Get current networks
|
||||
access_points = self.wifi_service.access_points
|
||||
known_networks = []
|
||||
other_networks = []
|
||||
|
||||
for access_point in access_points:
|
||||
if access_point.ssid and access_point.ssid != "Unknown":
|
||||
network_slot = WifiNetworkSlot(
|
||||
access_point, self.wifi_service, parent=self.parent
|
||||
)
|
||||
self.available_networks.add(network_slot)
|
||||
# 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):
|
||||
known_networks.append(access_point)
|
||||
else:
|
||||
other_networks.append(access_point)
|
||||
|
||||
# Add known networks
|
||||
for access_point in known_networks:
|
||||
network_slot = WifiNetworkSlot(
|
||||
access_point, self.wifi_service, parent=self.parent
|
||||
)
|
||||
self.known_networks.add(network_slot)
|
||||
|
||||
# Add other networks
|
||||
for access_point in other_networks:
|
||||
network_slot = WifiNetworkSlot(
|
||||
access_point, self.wifi_service, parent=self.parent
|
||||
)
|
||||
self.other_networks.add(network_slot)
|
||||
|
||||
# Show/hide sections based on available networks
|
||||
has_known_networks = len(known_networks) > 0
|
||||
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)
|
||||
|
||||
# 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()
|
||||
|
||||
def _is_saved_network(self, access_point):
|
||||
"""Check if a network is saved/known (placeholder implementation)"""
|
||||
# TODO: Implement proper saved network detection
|
||||
# This would typically check against NetworkManager's saved connections
|
||||
# For now, we'll use a simple heuristic
|
||||
return False
|
||||
|
||||
def refresh_network_states(self, *_):
|
||||
"""Refresh connection states for all network slots"""
|
||||
for child in self.available_networks.get_children():
|
||||
# Refresh known networks
|
||||
for child in self.known_networks.get_children():
|
||||
if hasattr(child, "on_changed"):
|
||||
child.on_changed()
|
||||
|
||||
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.scrolled_window.get_vadjustment()
|
||||
|
||||
# Track gesture state
|
||||
self.pull_start_y = 0
|
||||
self.is_pulling = False
|
||||
self.pull_threshold = 50 # pixels to trigger refresh
|
||||
|
||||
# Animation state
|
||||
self.bounce_timeout_id = None
|
||||
self.bounce_frame = 0
|
||||
self.bounce_duration = 60 # Total frames for animation
|
||||
# Maximum bounce height in pixels (increased for visibility)
|
||||
self.bounce_amplitude = 30
|
||||
|
||||
# Connect to scroll events
|
||||
self.scrolled_window.connect("scroll-event", self.on_scroll_event)
|
||||
self.scrolled_window.connect("button-press-event", self.on_button_press)
|
||||
self.scrolled_window.connect("button-release-event", self.on_button_release)
|
||||
self.scrolled_window.connect("motion-notify-event", self.on_motion_notify)
|
||||
|
||||
# Enable events
|
||||
self.scrolled_window.set_events(
|
||||
Gdk.EventMask.SCROLL_MASK
|
||||
| Gdk.EventMask.BUTTON_PRESS_MASK
|
||||
| Gdk.EventMask.BUTTON_RELEASE_MASK
|
||||
| Gdk.EventMask.POINTER_MOTION_MASK
|
||||
)
|
||||
|
||||
def on_scroll_event(self, widget, event):
|
||||
"""Handle scroll events for pull-to-refresh"""
|
||||
# Only handle pull-to-refresh when at the top
|
||||
if self.vadjustment.get_value() <= 0:
|
||||
if event.direction == Gdk.ScrollDirection.UP:
|
||||
# Scrolling up at the top - start scan
|
||||
if self.wifi_service:
|
||||
self.wifi_service.scan()
|
||||
# Trigger smooth bounce animation for scroll
|
||||
self.start_bounce_animation()
|
||||
return True # Consume the event
|
||||
return False # Let normal scrolling continue
|
||||
|
||||
def on_button_press(self, widget, event):
|
||||
"""Handle button press for touch/drag gestures"""
|
||||
if self.vadjustment.get_value() <= 0:
|
||||
self.pull_start_y = event.y
|
||||
self.is_pulling = True
|
||||
return False
|
||||
|
||||
def on_button_release(self, widget, event):
|
||||
"""Handle button release for touch/drag gestures"""
|
||||
if self.is_pulling:
|
||||
pull_distance = event.y - self.pull_start_y
|
||||
if pull_distance > self.pull_threshold:
|
||||
# Start scan
|
||||
if self.wifi_service:
|
||||
self.wifi_service.scan()
|
||||
# Trigger elastic animation for pull gesture (most satisfying)
|
||||
self.start_elastic_animation()
|
||||
else:
|
||||
# Stop any ongoing animation
|
||||
self.stop_bounce_animation()
|
||||
# Hide refresh indicator
|
||||
self.refresh_indicator.set_visible(False)
|
||||
self.refresh_indicator.remove_style_class("ready-to-refresh")
|
||||
self.is_pulling = False
|
||||
return False
|
||||
|
||||
def on_motion_notify(self, widget, event):
|
||||
"""Handle motion events for visual feedback during pull"""
|
||||
if self.is_pulling and self.vadjustment.get_value() <= 0:
|
||||
pull_distance = event.y - self.pull_start_y
|
||||
if pull_distance > 0:
|
||||
# Show refresh indicator when pulling down
|
||||
self.refresh_indicator.set_visible(True)
|
||||
if pull_distance >= self.pull_threshold:
|
||||
self.refresh_indicator.set_label("↑ Release to scan")
|
||||
self.refresh_indicator.add_style_class("ready-to-refresh")
|
||||
else:
|
||||
self.refresh_indicator.set_label("↓ Pull to scan for networks")
|
||||
self.refresh_indicator.remove_style_class("ready-to-refresh")
|
||||
else:
|
||||
self.refresh_indicator.set_visible(False)
|
||||
return False
|
||||
|
||||
def ease_out_bounce(self, t):
|
||||
"""Smooth bounce easing function (0 to 1)"""
|
||||
if t < 1 / 2.75:
|
||||
return 7.5625 * t * t
|
||||
elif t < 2 / 2.75:
|
||||
t -= 1.5 / 2.75
|
||||
return 7.5625 * t * t + 0.75
|
||||
elif t < 2.5 / 2.75:
|
||||
t -= 2.25 / 2.75
|
||||
return 7.5625 * t * t + 0.9375
|
||||
else:
|
||||
t -= 2.625 / 2.75
|
||||
return 7.5625 * t * t + 0.984375
|
||||
|
||||
def ease_out_elastic(self, t):
|
||||
"""Elastic easing function for smoother bounce"""
|
||||
import math
|
||||
|
||||
if t == 0 or t == 1:
|
||||
return t
|
||||
|
||||
p = 0.3
|
||||
s = p / 4
|
||||
return math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / p) + 1
|
||||
|
||||
def start_bounce_animation(self):
|
||||
"""Start smooth bounce animation for the refresh indicator"""
|
||||
if self.bounce_timeout_id:
|
||||
GLib.source_remove(self.bounce_timeout_id)
|
||||
|
||||
self.bounce_frame = 0
|
||||
# Higher frame rate for smoother animation (16ms = ~60fps)
|
||||
self.bounce_timeout_id = GLib.timeout_add(16, self.animate_bounce)
|
||||
|
||||
def animate_bounce(self):
|
||||
"""Animate smooth bounce effect with easing"""
|
||||
if self.bounce_frame >= self.bounce_duration:
|
||||
# Animation finished
|
||||
self.refresh_indicator.set_margin_top(0)
|
||||
self.bounce_timeout_id = None
|
||||
# Hide indicator after animation if not pulling
|
||||
if not self.is_pulling:
|
||||
self.refresh_indicator.set_visible(False)
|
||||
return False
|
||||
|
||||
# Calculate progress (0 to 1)
|
||||
progress = self.bounce_frame / self.bounce_duration
|
||||
|
||||
# Apply easing function for smooth bounce
|
||||
eased_progress = self.ease_out_bounce(progress)
|
||||
|
||||
# Calculate bounce offset (starts high, bounces down to 0)
|
||||
bounce_offset = int(self.bounce_amplitude * (1 - eased_progress))
|
||||
self.refresh_indicator.set_margin_top(max(0, bounce_offset))
|
||||
|
||||
self.bounce_frame += 1
|
||||
return True
|
||||
|
||||
def start_elastic_animation(self):
|
||||
"""Start elastic animation for more dramatic effect"""
|
||||
if self.bounce_timeout_id:
|
||||
GLib.source_remove(self.bounce_timeout_id)
|
||||
|
||||
self.bounce_frame = 0
|
||||
self.bounce_duration = 80 # Longer for elastic effect
|
||||
# Higher amplitude (increased for visibility)
|
||||
self.bounce_amplitude = 40
|
||||
self.bounce_timeout_id = GLib.timeout_add(16, self.animate_elastic)
|
||||
|
||||
def animate_elastic(self):
|
||||
"""Animate elastic bounce effect"""
|
||||
if self.bounce_frame >= self.bounce_duration:
|
||||
# Animation finished
|
||||
self.refresh_indicator.set_margin_top(0)
|
||||
self.bounce_timeout_id = None
|
||||
# Hide indicator after animation if not pulling
|
||||
if not self.is_pulling:
|
||||
self.refresh_indicator.set_visible(False)
|
||||
return False
|
||||
|
||||
# Calculate progress (0 to 1)
|
||||
progress = self.bounce_frame / self.bounce_duration
|
||||
|
||||
# Apply elastic easing
|
||||
eased_progress = self.ease_out_elastic(progress)
|
||||
|
||||
# Calculate bounce offset
|
||||
bounce_offset = int(self.bounce_amplitude * (1 - eased_progress))
|
||||
self.refresh_indicator.set_margin_top(max(0, bounce_offset))
|
||||
|
||||
self.bounce_frame += 1
|
||||
return True
|
||||
|
||||
def stop_bounce_animation(self):
|
||||
"""Stop any ongoing animations"""
|
||||
if self.bounce_timeout_id:
|
||||
GLib.source_remove(self.bounce_timeout_id)
|
||||
self.bounce_timeout_id = None
|
||||
self.refresh_indicator.set_margin_top(0)
|
||||
# Refresh other networks
|
||||
for child in self.other_networks.get_children():
|
||||
if hasattr(child, "on_changed"):
|
||||
child.on_changed()
|
||||
|
||||
def on_destroy(self, widget):
|
||||
"""Cleanup animations when widget is destroyed"""
|
||||
self.stop_bounce_animation()
|
||||
"""Cleanup when widget is destroyed"""
|
||||
# Make sure other networks revealer is collapsed when closing
|
||||
self.other_networks_revealer.child_revealed = False
|
||||
|
||||
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
|
||||
@@ -5,6 +5,13 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#separator {
|
||||
min-height: 2px;
|
||||
margin: 2px 0;
|
||||
background-color: var(--outline);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#bluetooth-control-window,
|
||||
#wifi-control-window,
|
||||
#battery-control-window {
|
||||
@@ -188,10 +195,10 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#bluetooth-widget-top {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid alpha(#aaa, 0.3);
|
||||
}
|
||||
/* #bluetooth-widget-top { */
|
||||
/* padding-bottom: 10px; */
|
||||
/* border-bottom: 1px solid alpha(#aaa, 0.3); */
|
||||
/* } */
|
||||
|
||||
#device-icon {
|
||||
border-radius: 50%;
|
||||
@@ -824,3 +831,62 @@
|
||||
margin: -5px;
|
||||
box-shadow: 0 2px 6px alpha(#000, 0.4);
|
||||
}
|
||||
|
||||
/* macOS-style WiFi interface */
|
||||
#wifi-connections,
|
||||
#bluetooth-connections {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* WiFi network slots - macOS style */
|
||||
#wifi-network-slot button {
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
/* min-width: 280px; */
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
#wifi-network-slot button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* WiFi-specific device icon styles */
|
||||
#wifi-connections #device-icon {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#wifi-connections #device-icon.connected {
|
||||
background-color: #007aff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#wifi-network-name {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#wifi-lock-icon {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Other Networks and Settings buttons */
|
||||
#wifi-other-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#wifi-other-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#wifi-other-button label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
Reference in new issue
Block a user