Huge updates: ControlCenter, DropDown Menu

This commit is contained in:
sankalp committed 2025-07-31 20:46:02 +05:45
1 parent 1f2d0a33f4
commit 9d83d960ef
34 files changed
+3261 -1440

No files matched your search

+363
View File
@@ -0,0 +1,363 @@
from gi.repository import Gtk, Gdk, GLib
from fabric.bluetooth import BluetoothClient, BluetoothDevice
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.eventbox import EventBox
class BluetoothDeviceSlot(CenterBox):
def __init__(self, device: BluetoothDevice, **kwargs):
super().__init__(**kwargs)
self.device = device
self.device.connect("changed", self.on_changed)
self.device.connect(
"notify::closed", lambda *_: self.device.closed and self.destroy()
)
self.styles = [
"connected" if self.device.connected else "",
"paired" if self.device.paired else "",
]
self.dimage = Image(
icon_name=device.icon_name + "-symbolic", # type: ignore
size=5,
name="device-icon",
style_classes=" ".join(self.styles),
)
self.start_children = [
Button(
image=self.dimage,
on_clicked=lambda *_: self.toggle_connecting(),
),
Label(label=device.name), # type: ignore
]
self.device.emit("changed") # to update display status
def toggle_connecting(self):
self.device.emit("changed")
self.device.set_connecting(not self.device.connected)
def on_changed(self, *_):
self.styles = [
"connected" if self.device.connected else "",
"paired" if self.device.paired else "",
]
self.dimage.set_property("style-classes", " ".join(self.styles))
return
class BluetoothConnections(Box):
def __init__(self, parent, show_hidden_devices: bool = False, **kwargs):
super().__init__(
spacing=4,
orientation="vertical",
style="margin: 8px",
**kwargs,
)
self.parent = parent
self.show_hidden_devices = show_hidden_devices
self.client = BluetoothClient(on_device_added=self.on_device_added)
self.title = Box(
orientation="h",
children=[
Button(
image=Image(icon_name="back", size=10),
on_clicked=lambda *_: self.parent.close_bluetooth(),
),
Label("Bluetooth"),
],
)
self.toggle_button = Gtk.Switch(visible=True, name="toggle-button")
self.toggle_button.set_active(self.client.enabled)
self.toggle_button.connect(
"notify::active",
lambda *_: self.client.set_enabled(self.toggle_button.get_active()),
)
self.client.connect(
"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")
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
name="bluetooth-refresh-indicator",
label="↓ Pull to scan for devices",
h_align="center",
visible=False
)
self.device_box = Box(
spacing=2,
orientation="vertical",
children=[self.refresh_indicator, self.paired, self.not_paired]
)
# 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,
overlay_scroll=True,
)
# Add pull-to-refresh functionality
self.setup_pull_to_refresh()
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.client.notify("scanning")
self.client.notify("enabled")
# Connect cleanup on destroy
self.connect("destroy", self.on_destroy)
def on_destroy(self, widget):
"""Cleanup animations when widget is destroyed"""
self.stop_bounce_animation()
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
self.bounce_amplitude = 15 # Maximum bounce height in pixels
# 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 - toggle scan (start or stop)
self.client.toggle_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:
# Toggle scan (start or stop)
self.client.toggle_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:
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 ease_out_bounce(self, t):
"""Smooth bounce easing function (0 to 1)"""
import math
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
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
self.bounce_amplitude = 20 # Higher amplitude
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
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)
def update_scan_label(self):
"""Update scanning state appearance"""
if self.client.scanning:
# Show scanning feedback in refresh indicator
self.refresh_indicator.set_label("Scanning for devices...")
self.refresh_indicator.set_visible(True)
self.refresh_indicator.add_style_class("scanning")
else:
# Hide scanning feedback
if not self.is_pulling:
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("scanning")
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)
+9
View File
@@ -0,0 +1,9 @@
<esh name="ControlCenter">
<BoxV h_expand="true" name="control-center-widgets">
<BoxH name="top-widget" h_expand="true">
<BoxV name="wb-widget" style_classes="menu" spacing="5">
<Ref>self.bluetooth_man</Ref>
</BoxV>
</BoxH>
</BoxV>
</esh>
+27
View File
@@ -0,0 +1,27 @@
<esh name="ControlCenter">
<BoxV h_expand="true" name="control-center-widgets">
<BoxH name="top-widget" h_expand="true">
<BoxV name="wb-widget" style_classes="menu" spacing="5">
<Ref>self.wlan_widget</Ref>
<Ref>self.bluetooth_widget</Ref>
</BoxV>
<BoxH name="dnd-widget" style_classes="menu" h_expand="true">
<Ref>self.focus_widget</Ref>
<Svg path="config/assets/icons/dnd.svg" name="dnd-menu-icon"/>
</BoxH>
</BoxH>
<BoxV name="brightness-widget" style_classes="menu" h_expand="true">
<Label label="Display" style_classes="title" h_align="start"/>
<Ref>self.brightness_scale</Ref>
<Label label="󰖨 " name="brightness-widget-icon" h_align="start"/>
</BoxV>
<BoxV name="volume-widget" style_classes="menu" h_expand="true">
<Label label="Sound" style_classes="title" h_align="start"/>
<Ref>self.volume_scale</Ref>
<Label label=" " name="brightness-widget-icon" h_align="start"/>
</BoxV>
<BoxV>
<Ref>self.music_widget</Ref>
</BoxV>
</BoxV>
</esh>
+372
View File
@@ -0,0 +1,372 @@
from fabric.utils import idle_add
from fabric.utils.helpers 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.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.svg import Svg
from gi.repository import Gdk, GLib
from modules.controlcenter.bluetooth import BluetoothConnections
from modules.controlcenter.wifi import WifiConnections
from services.brightness import Brightness
from utils.exml import exml
from utils.roam import audio_service, modus_service
from widgets.wayland import WaylandWindow as Window
brightness_service = Brightness.get_initial()
# FIX: Icon not showing up in control center
# TODO: Add Player
class ModusControlCenter(Window):
def __init__(self, **kwargs):
super().__init__(
layer="top",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="control-center-menu",
visible=False,
**kwargs,
)
self.focus_mode = False
self._updating_brightness = False
self._updating_volume = False
self.add_keybinding("Escape", self.hide_controlcenter)
volume = 100
wlan = modus_service.sc("wlan-changed", self.wlan_changed)
bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed)
music = modus_service.sc("music-changed", self.audio_changed)
audio_service.connect("changed", self.audio_changed)
audio_service.connect("changed", self.volume_changed)
self.wlan_label = Label(wlan, name="wifi-widget-label", h_align="start")
self.bluetooth_label = Label(
bluetooth, name="bluetooth-widget-label", h_align="start"
)
self.volume_scale = Scale(
value=volume,
min_value=0,
max_value=100,
increments=(5, 5),
name="volume-widget-slider",
size=30,
h_expand=True,
)
self.volume_scale.connect("change-value", self.set_volume)
self.volume_scale.connect("scroll-event", self.on_volume_scroll)
current_brightness = brightness_service.screen_brightness
brightness_percentage = (
int((current_brightness / brightness_service.max_screen) * 100)
if brightness_service.max_screen > 0
else 50
)
self.brightness_scale = Scale(
value=brightness_percentage,
min_value=0,
max_value=100,
increments=(5, 5),
name="brightness-widget-slider",
size=30,
h_expand=True,
)
# Only connect brightness controls if brightness service is available
if brightness_service.max_screen > 0:
self.brightness_scale.connect("change-value", self.set_brightness)
self.brightness_scale.connect("scroll-event", self.on_brightness_scroll)
brightness_service.connect("screen", self.brightness_changed)
else:
# Disable brightness scale if no backlight device available
self.brightness_scale.set_sensitive(False)
self.music_widget = Box(
name="music-widget",
# children=[Player()],
)
self.wifi_man = WifiConnections(self)
self.bluetooth_man = BluetoothConnections(self)
self.has_bluetooth_open = False
self.has_wifi_open = False
self.bluetooth_svg = Svg(
svg_file=get_relative_path(
"../../config/assets/icons/bluetooth.svg"
if bluetooth == "On"
else "../../config/assets/icons/bluetooth-off.svg"
),
style_classes="icon",
)
self.wifi_svg = (
Svg(
svg_file=get_relative_path("../../config/assets/icons/wifi.svg"),
style_classes="icon",
)
if wlan != "No Connection"
else Svg(
svg_file=get_relative_path("../../config/assets/icons/wifi-off.svg"),
style_classes="icon",
)
)
self.bluetooth_widget = Button(
name="bluetooth-widget",
child=Box(
orientation="h",
children=[
self.bluetooth_svg,
Box(
name="bluetooth-widget-info",
orientation="vertical",
children=[
Label(
label="Bluetooth",
style_classes="title ct",
h_align="start",
),
self.bluetooth_label,
],
),
],
),
on_clicked=self.open_bluetooth,
)
self.wlan_widget = Button(
name="wifi-widget",
child=Box(
orientation="h",
children=[
self.wifi_svg,
Box(
name="wifi-widget-info",
orientation="vertical",
children=[
Label(
label="Wi-Fi", style_classes="title ct", h_align="start"
),
self.wlan_label,
],
),
],
),
on_clicked=self.open_wifi,
)
self.focus_icon = Svg(
svg_file=get_relative_path("../../config/assets/icons/dnd-off.svg"),
style_classes="icon",
)
self.focus_widget = Button(
name="focus-widget",
child=Box(
orientation="h",
children=[
self.focus_icon,
Label(label="Focus", style_classes="title ct", h_align="start"),
],
),
on_clicked=self.set_dont_disturb,
)
self.widgets = exml(
file=get_relative_path("controlcenter.xml"),
root=Box,
tags={
"Box": Box,
"Button": Button,
"Label": Label,
"Scale": Scale,
"Svg": Svg,
},
refs={
"self.wlan_label": self.wlan_label,
"self.bluetooth_label": self.bluetooth_label,
"self.volume_scale": self.volume_scale,
"self.brightness_scale": self.brightness_scale,
"self.music_widget": self.music_widget,
"self.bluetooth_widget": self.bluetooth_widget,
"self.wlan_widget": self.wlan_widget,
"self.focus_widget": self.focus_widget,
},
)
self.bluetooth_widgets = exml(
file=get_relative_path("bluetooth.xml"),
root=Box,
tags={
"Box": Box,
"Button": Button,
"Label": Label,
"Scale": Scale,
"Svg": Svg,
},
refs={"self.bluetooth_man": self.bluetooth_man},
)
self.wifi_widgets = exml(
file=get_relative_path("wifi.xml"),
root=Box,
tags={
"Box": Box,
"Button": Button,
"Label": Label,
"Scale": Scale,
"Svg": Svg,
},
refs={"self.wifi_man": self.wifi_man},
)
self.center_box = CenterBox(start_children=[self.widgets])
self.bluetooth_center_box = CenterBox(start_children=[self.bluetooth_widgets])
self.wifi_center_box = CenterBox(start_children=[self.wifi_widgets])
self.widgets.set_size_request(300, -1)
self.bluetooth_center_box.set_size_request(300, -1)
self.wifi_center_box.set_size_request(300, -1)
self.children = self.center_box
def set_dont_disturb(self, *_):
self.focus_mode = not self.focus_mode
modus_service.dont_disturb = self.focus_mode
self.focus_icon.set_from_file(
get_relative_path(
"../../config/assets/icons/dnd.svg"
if self.focus_mode
else "../../config/assets/icons/dnd-off.svg"
)
)
def set_volume(self, _, __, volume):
self._updating_volume = True
audio_service.speaker.volume = round(volume)
self._updating_volume = False
def set_brightness(self, _, __, brightness):
self._updating_brightness = True
brightness_value = int((brightness / 100) * brightness_service.max_screen)
brightness_service.screen_brightness = brightness_value
self._updating_brightness = False
def brightness_changed(self, _, brightness_value):
if self._updating_brightness:
return
if brightness_service.max_screen > 0:
brightness_percentage = int(
(brightness_value / brightness_service.max_screen) * 100
)
GLib.idle_add(
lambda: self.brightness_scale.set_value(brightness_percentage)
)
def on_volume_scroll(self, widget, event):
current_value = self.volume_scale.get_value()
scroll_step = 5
if event.direction == Gdk.ScrollDirection.UP:
new_value = min(100, current_value + scroll_step)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_value = max(0, current_value - scroll_step)
else:
return False
self.volume_scale.set_value(new_value)
return True
def on_brightness_scroll(self, widget, event):
current_value = self.brightness_scale.get_value()
scroll_step = 5
if event.direction == Gdk.ScrollDirection.UP:
new_value = min(100, current_value + scroll_step)
elif event.direction == Gdk.ScrollDirection.DOWN:
new_value = max(0, current_value - scroll_step)
else:
return False
self.brightness_scale.set_value(new_value)
return True
def set_children(self, children):
self.children = children
def open_bluetooth(self, *_):
idle_add(lambda *_: self.set_children(self.bluetooth_center_box))
self.has_bluetooth_open = True
def open_wifi(self, *_):
idle_add(lambda *_: self.set_children(self.wifi_center_box))
self.has_wifi_open = True
def close_bluetooth(self, *_):
idle_add(lambda *_: self.set_children(self.center_box))
self.has_bluetooth_open = False
def close_wifi(self, *_):
idle_add(lambda *_: self.set_children(self.center_box))
self.has_wifi_open = False
def _set_mousecapture(self, visible: bool):
self.set_visible(visible)
if not visible:
self.close_bluetooth()
self.close_wifi()
def volume_changed(
self,
_,
):
if self._updating_volume:
return
GLib.idle_add(
lambda: self.volume_scale.set_value(int(audio_service.speaker.volume))
)
def wlan_changed(self, _, wlan):
self.wifi_svg.set_from_file(
get_relative_path(
"../../config/assets/icons/wifi.svg"
if wlan != "No Connection"
else "../../config/assets/icons/wifi-off.svg"
)
)
GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
def bluetooth_changed(self, _, bluetooth):
self.bluetooth_svg.set_from_file(
get_relative_path(
"../../config/assets/icons/bluetooth.svg"
if bluetooth == "On"
else "../../config/assets/icons/bluetooth-off.svg"
)
)
GLib.idle_add(lambda: self.bluetooth_label.set_property("label", bluetooth))
def audio_changed(self, *_):
pass
def _init_mousecapture(self, mousecapture):
self._mousecapture_parent = mousecapture
def hide_controlcenter(self, *_):
self._mousecapture_parent.toggle_mousecapture()
self.set_visible(False)
View File
Whitespace-only changes.
+430
View File
@@ -0,0 +1,430 @@
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 gi.repository import Gdk, GLib, Gtk
from services.network import NetworkService
# FIX: Make sure the network only scan when the pull to refresh started
# TODO: Show visual Feeadback and animation like bluetooth
class WifiNetworkSlot(CenterBox):
def __init__(self, network_data: dict, wifi_service, **kwargs):
super().__init__(**kwargs)
self.network_data = network_data
self.wifi_service = wifi_service
# Get network info
self.ssid = network_data.get("ssid", "Unknown")
self.bssid = network_data.get("bssid", "")
self.strength = network_data.get("strength", 0)
self.icon_name = network_data.get(
"icon-name", "network-wireless-signal-none-symbolic"
)
# The network service puts the same active-ap reference in all networks
# We need to check if THIS specific network's BSSID matches the active AP's BSSID
self.is_connected = False
if self.wifi_service and self.wifi_service._ap:
active_bssid = self.wifi_service._ap.get_bssid()
self.is_connected = active_bssid == self.bssid
# Initialize styles based on connection state
self.styles = [
"connected" if self.is_connected else "",
]
# Create connection status indicator using symbolic WiFi icon
self.dimage = Image(
icon_name="network-wireless-symbolic",
size=5,
name="device-icon",
style_classes=" ".join(self.styles),
)
self.start_children = [
Button(
image=self.dimage,
on_clicked=lambda *_: self.toggle_connecting(),
),
Label(label=self.ssid),
]
# Emit initial change to update display
self.on_changed()
def toggle_connecting(self):
"""Toggle WiFi network connection"""
# Check if this network is currently connected by comparing BSSIDs
is_currently_connected = False
if self.wifi_service and self.wifi_service._ap:
active_bssid = self.wifi_service._ap.get_bssid()
is_currently_connected = active_bssid == self.bssid
if is_currently_connected:
# Disconnect from network
success = self.wifi_service.disconnect_network(self.ssid)
if success:
self.is_connected = False
else:
# Connect to network (try without password first, could be enhanced for password input)
success = self.wifi_service.connect_network(self.ssid)
if success:
self.is_connected = True
# Update display after connection attempt
self.on_changed()
def on_changed(self, *_):
"""Update display when connection state changes"""
# Check if this network is currently connected by comparing BSSIDs
is_currently_connected = False
if self.wifi_service and self.wifi_service._ap:
active_bssid = self.wifi_service._ap.get_bssid()
is_currently_connected = active_bssid == self.bssid
self.is_connected = is_currently_connected
self.styles = [
"connected" if self.is_connected else "",
]
self.dimage.set_property("style-classes", " ".join(self.styles))
return
class WifiConnections(Box):
def __init__(self, parent, **kwargs):
super().__init__(
spacing=4,
orientation="vertical",
style="margin: 8px",
**kwargs,
)
self.parent = parent
self.network_service = NetworkService()
self.wifi_service = None
# Wait for network service to be ready
self.network_service.connect("device-ready", self.on_network_ready)
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
name="wifi-refresh-indicator",
label="↓ Pull to scan for networks",
h_align="center",
visible=False,
)
self.title = Box(
orientation="h",
children=[
Button(
image=Image(icon_name="back", size=10),
on_clicked=lambda *_: self.parent.close_wifi(),
),
Label("WiFi"),
],
)
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 scrolled window with pull-to-refresh
self.scrolled_window = ScrolledWindow(
min_content_size=(303, 400),
max_content_size=(303, 800),
child=self.device_box,
overlay_scroll=True,
)
# Add pull-to-refresh functionality
self.setup_pull_to_refresh()
self.children = [
CenterBox(
start_children=self.title,
end_children=self.toggle_button,
name="wifi-widget-top",
),
Label("Networks", h_align="start", name="networks-title"),
self.scrolled_window,
]
# Connect cleanup on destroy
self.connect("destroy", self.on_destroy)
def on_network_ready(self, *_):
"""Called when network service is ready"""
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
# Set up WiFi toggle
self.toggle_button.set_active(self.wifi_service.enabled)
self.toggle_button.connect(
"notify::active",
lambda *_: setattr(
self.wifi_service, "enabled", self.toggle_button.get_active()
),
)
# Connect to WiFi service signals
self.wifi_service.connect("notify::enabled", self.on_wifi_enabled_changed)
self.wifi_service.connect("scanning", self.on_scanning_changed)
self.wifi_service.connect("changed", self.update_networks)
# Also connect to access point changes to update connection states
self.wifi_service.connect(
"notify::access-points", self.refresh_network_states
)
# Initial network update
self.update_networks()
def on_wifi_enabled_changed(self, *_):
"""Handle WiFi enabled state changes"""
if self.wifi_service:
self.toggle_button.set_active(self.wifi_service.enabled)
def on_scanning_changed(self, service, is_scanning):
"""Handle scanning state changes"""
print(f"WiFi scanning state changed: {is_scanning}") # Debug output
self.update_scan_indicator(is_scanning)
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():
child.destroy()
# Add current networks
access_points = self.wifi_service.access_points
for ap_data in access_points:
if ap_data.get("ssid") and ap_data.get("ssid") != "Unknown":
network_slot = WifiNetworkSlot(ap_data, self.wifi_service)
self.available_networks.add(network_slot)
# Update all network connection states
self.refresh_network_states()
def refresh_network_states(self, *_):
"""Refresh connection states for all network slots"""
for child in self.available_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
self.bounce_amplitude = 15 # Maximum bounce height in pixels
# 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
print("WiFi scroll scan triggered") # Debug output
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:
# Trigger scan
print("WiFi pull-to-refresh scan triggered") # Debug output
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 update_scan_indicator(self, is_scanning):
"""Update scanning state appearance"""
print(f"WiFi update_scan_indicator called: {is_scanning}") # Debug output
if is_scanning:
# Show scanning feedback in refresh indicator
self.refresh_indicator.set_label("Scanning for networks...")
self.refresh_indicator.set_visible(True)
self.refresh_indicator.add_style_class("scanning")
print("WiFi scanning indicator shown") # Debug output
else:
# Hide scanning feedback
if not self.is_pulling:
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("scanning")
print("WiFi scanning indicator hidden") # Debug output
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"""
print("WiFi bounce animation started") # Debug output
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
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"""
print("WiFi elastic animation started") # Debug output
if self.bounce_timeout_id:
GLib.source_remove(self.bounce_timeout_id)
self.bounce_frame = 0
self.bounce_duration = 80 # Longer for elastic effect
self.bounce_amplitude = 20 # Higher amplitude
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
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)
def on_destroy(self, widget):
"""Cleanup animations when widget is destroyed"""
self.stop_bounce_animation()
+9
View File
@@ -0,0 +1,9 @@
<esh name="ControlCenter">
<BoxV h_expand="true" name="control-center-widgets">
<BoxH name="top-widget" h_expand="true">
<BoxV name="wb-widget" style_classes="menu" spacing="5">
<Ref>self.wifi_man</Ref>
</BoxV>
</BoxH>
</BoxV>
</esh>