✨ feat(controlcenter): redesign Wi-Fi and Bluetooth panels with macOS-style UI

This commit is contained in:
sankalp committed 2025-08-11 00:44:34 +05:45
1 parent d30eb019f0
commit 9a20337ced
3 files changed
+462 -391

No files matched your search

+205 -151
View File
@@ -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
View File
@@ -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