Huge updates: ControlCenter: Wi-Fi, Bluetooth, Password Entry Dialog, Notification, CustomRevealer for notif
This commit is contained in:
31 files changed
+1418
-349
No files matched your search
@@ -13,6 +13,36 @@ APP_NAME_CAP = "Modus"
|
||||
ALLOWED_PLAYERS = ["vlc", "cmus", "firefox", "spotify", "chromium", "vivaldi", "brave"]
|
||||
|
||||
|
||||
def parse_timeout_string(timeout_str):
|
||||
"""
|
||||
Parse timeout string in format like '5s', '10m', '30s' etc.
|
||||
Returns timeout in milliseconds.
|
||||
"""
|
||||
if not timeout_str or not isinstance(timeout_str, str):
|
||||
return 5000
|
||||
|
||||
timeout_str = timeout_str.strip().lower()
|
||||
|
||||
if timeout_str.endswith("s"):
|
||||
try:
|
||||
seconds = int(timeout_str[:-1])
|
||||
return seconds * 1000
|
||||
except ValueError:
|
||||
return 5000
|
||||
elif timeout_str.endswith("m"):
|
||||
try:
|
||||
minutes = int(timeout_str[:-1])
|
||||
return minutes * 60 * 1000
|
||||
except ValueError:
|
||||
return 5000
|
||||
else:
|
||||
try:
|
||||
seconds = int(timeout_str)
|
||||
return seconds * 1000
|
||||
except ValueError:
|
||||
return 5000
|
||||
|
||||
|
||||
CACHE_DIR = str(GLib.get_user_cache_dir()) + f"/{APP_NAME}"
|
||||
|
||||
USERNAME = os.getlogin()
|
||||
@@ -72,6 +102,9 @@ if os.path.exists(CONFIG_FILE):
|
||||
"dock_hide_special_workspace_apps", True
|
||||
)
|
||||
|
||||
NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s")
|
||||
NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
|
||||
|
||||
DOCK_COMPONENTS_VISIBILITY = {
|
||||
"workspace": config.get("dock_workspace_visible", True),
|
||||
"metrics": config.get("dock_metrics_visible", True),
|
||||
@@ -104,6 +137,9 @@ else:
|
||||
DOCK_HIDE_SPECIAL_WORKSPACE = True
|
||||
DOCK_HIDE_SPECIAL_WORKSPACE_APPS = True
|
||||
|
||||
NOTIFICATION_TIMEOUT_STR = "5s"
|
||||
NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR)
|
||||
|
||||
DOCK_COMPONENTS_VISIBILITY = {
|
||||
"workspace": True,
|
||||
"metrics": True,
|
||||
|
||||
+4
-8
@@ -1,13 +1,9 @@
|
||||
import subprocess
|
||||
import re
|
||||
import gi # type: ignore
|
||||
import subprocess
|
||||
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.box import Box
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
from fabric.utils.helpers import exec_shell_command_async, get_relative_path
|
||||
from gi.repository import Gtk, GdkPixbuf # type: ignore
|
||||
from gi.repository import GdkPixbuf, Gtk # type: ignore
|
||||
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
|
||||
|
||||
def read_dmi(field):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from gi.repository import Gtk, Gdk, GLib
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
from fabric.bluetooth import BluetoothClient, BluetoothDevice
|
||||
from fabric.widgets.box import Box
|
||||
@@ -7,7 +7,6 @@ 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):
|
||||
@@ -68,8 +67,6 @@ class BluetoothConnections(Box):
|
||||
|
||||
self.client = BluetoothClient(on_device_added=self.on_device_added)
|
||||
|
||||
|
||||
|
||||
self.title = Box(
|
||||
orientation="h",
|
||||
children=[
|
||||
@@ -95,10 +92,7 @@ class BluetoothConnections(Box):
|
||||
)
|
||||
|
||||
# Connect scanning state changes to update scan button
|
||||
self.client.connect(
|
||||
"notify::scanning",
|
||||
lambda *_: self.update_scan_label()
|
||||
)
|
||||
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")
|
||||
@@ -108,13 +102,13 @@ class BluetoothConnections(Box):
|
||||
name="bluetooth-refresh-indicator",
|
||||
label="↓ Pull to scan for devices",
|
||||
h_align="center",
|
||||
visible=False
|
||||
visible=False,
|
||||
)
|
||||
|
||||
self.device_box = Box(
|
||||
spacing=2,
|
||||
orientation="vertical",
|
||||
children=[self.refresh_indicator, self.paired, self.not_paired]
|
||||
children=[self.refresh_indicator, self.paired, self.not_paired],
|
||||
)
|
||||
|
||||
# Create scrolled window with pull-to-refresh
|
||||
@@ -143,8 +137,6 @@ class BluetoothConnections(Box):
|
||||
# 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()
|
||||
@@ -165,8 +157,6 @@ class BluetoothConnections(Box):
|
||||
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)
|
||||
@@ -175,10 +165,10 @@ class BluetoothConnections(Box):
|
||||
|
||||
# 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
|
||||
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):
|
||||
@@ -243,22 +233,22 @@ class BluetoothConnections(Box):
|
||||
|
||||
def ease_out_bounce(self, t):
|
||||
"""Smooth bounce easing function (0 to 1)"""
|
||||
import math
|
||||
if t < 1/2.75:
|
||||
if t < 1 / 2.75:
|
||||
return 7.5625 * t * t
|
||||
elif t < 2/2.75:
|
||||
t -= 1.5/2.75
|
||||
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
|
||||
elif t < 2.5 / 2.75:
|
||||
t -= 2.25 / 2.75
|
||||
return 7.5625 * t * t + 0.9375
|
||||
else:
|
||||
t -= 2.625/2.75
|
||||
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
|
||||
|
||||
@@ -327,8 +317,6 @@ class BluetoothConnections(Box):
|
||||
self.bounce_frame += 1
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def stop_bounce_animation(self):
|
||||
"""Stop any ongoing animations"""
|
||||
if self.bounce_timeout_id:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
from fabric.utils import idle_add
|
||||
from fabric.utils.helpers import (
|
||||
get_relative_path,
|
||||
@@ -8,9 +10,6 @@ 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 fabric.widgets.image import Image
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
from modules.controlcenter.bluetooth import BluetoothConnections
|
||||
from modules.controlcenter.wifi import WifiConnections
|
||||
from services.brightness import Brightness
|
||||
@@ -37,7 +36,7 @@ class ModusControlCenter(Window):
|
||||
visible=False,
|
||||
**kwargs,
|
||||
)
|
||||
self.focus_mode = False
|
||||
self.focus_mode = modus_service.dont_disturb
|
||||
self._updating_brightness = False
|
||||
self._updating_volume = False
|
||||
|
||||
@@ -50,6 +49,7 @@ class ModusControlCenter(Window):
|
||||
|
||||
audio_service.connect("changed", self.audio_changed)
|
||||
audio_service.connect("changed", self.volume_changed)
|
||||
modus_service.connect("dont-disturb-changed", self.dnd_changed)
|
||||
|
||||
self.wlan_label = Label(wlan, name="wifi-widget-label", h_align="start")
|
||||
if bluetooth != "disabled":
|
||||
@@ -178,7 +178,11 @@ class ModusControlCenter(Window):
|
||||
|
||||
self.focus_icon = Svg(
|
||||
name="focus-icon",
|
||||
svg_file=get_relative_path("../../config/assets/icons/applets/dnd-off.svg"),
|
||||
svg_file=get_relative_path(
|
||||
"../../config/assets/icons/applets/dnd.svg"
|
||||
if self.focus_mode
|
||||
else "../../config/assets/icons/applets/dnd-off.svg"
|
||||
),
|
||||
size=46,
|
||||
)
|
||||
|
||||
@@ -364,9 +368,13 @@ class ModusControlCenter(Window):
|
||||
parts = wlan.split(":")
|
||||
if len(parts) >= 2:
|
||||
wifi_name = parts[1]
|
||||
GLib.idle_add(lambda: self.wlan_label.set_property("label", wifi_name))
|
||||
GLib.idle_add(
|
||||
lambda: self.wlan_label.set_property("label", wifi_name)
|
||||
)
|
||||
else:
|
||||
GLib.idle_add(lambda: self.wlan_label.set_property("label", "Connected"))
|
||||
GLib.idle_add(
|
||||
lambda: self.wlan_label.set_property("label", "Connected")
|
||||
)
|
||||
else:
|
||||
GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan))
|
||||
else:
|
||||
@@ -385,9 +393,13 @@ class ModusControlCenter(Window):
|
||||
parts = bluetooth.split(":")
|
||||
if len(parts) >= 2:
|
||||
device_name = parts[1]
|
||||
GLib.idle_add(lambda: self.bluetooth_label.set_property("label", device_name))
|
||||
GLib.idle_add(
|
||||
lambda: self.bluetooth_label.set_property("label", device_name)
|
||||
)
|
||||
else:
|
||||
GLib.idle_add(lambda: self.bluetooth_label.set_property("label", "Connected"))
|
||||
GLib.idle_add(
|
||||
lambda: self.bluetooth_label.set_property("label", "Connected")
|
||||
)
|
||||
elif bluetooth == "enabled":
|
||||
GLib.idle_add(lambda: self.bluetooth_label.set_property("label", "On"))
|
||||
else:
|
||||
@@ -398,6 +410,16 @@ class ModusControlCenter(Window):
|
||||
def audio_changed(self, *_):
|
||||
pass
|
||||
|
||||
def dnd_changed(self, _, dnd_state):
|
||||
self.focus_mode = dnd_state
|
||||
self.focus_icon.set_from_file(
|
||||
get_relative_path(
|
||||
"../../config/assets/icons/applets/dnd.svg"
|
||||
if self.focus_mode
|
||||
else "../../config/assets/icons/applets/dnd-off.svg"
|
||||
)
|
||||
)
|
||||
|
||||
def _init_mousecapture(self, mousecapture):
|
||||
self._mousecapture_parent = mousecapture
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
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
|
||||
from widgets.wifi_password_dialog import WiFiPasswordDialog
|
||||
|
||||
|
||||
class WifiNetworkSlot(CenterBox):
|
||||
def __init__(self, network_data: dict, wifi_service, **kwargs):
|
||||
def __init__(self, network_data: dict, wifi_service, parent=None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.network_data = network_data
|
||||
self.wifi_service = wifi_service
|
||||
self.parent = parent # Reference to control center
|
||||
|
||||
# Get network info
|
||||
self.ssid = network_data.get("ssid", "Unknown")
|
||||
@@ -43,6 +45,9 @@ class WifiNetworkSlot(CenterBox):
|
||||
style_classes=" ".join(self.styles),
|
||||
)
|
||||
|
||||
# Initialize password dialog
|
||||
self.password_dialog = None
|
||||
|
||||
self.start_children = [
|
||||
Button(
|
||||
image=self.dimage,
|
||||
@@ -79,11 +84,10 @@ class WifiNetworkSlot(CenterBox):
|
||||
# Reset state if disconnect failed
|
||||
self._reset_disconnect_state()
|
||||
else:
|
||||
# Show connecting state
|
||||
# Try to connect without password first (for open networks or saved connections)
|
||||
self.dimage.set_property("icon-name", "network-wireless-acquiring-symbolic")
|
||||
self.dimage.add_style_class("connecting")
|
||||
|
||||
# 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
|
||||
@@ -92,8 +96,9 @@ class WifiNetworkSlot(CenterBox):
|
||||
|
||||
GLib.timeout_add(500, lambda: self._reset_connect_state())
|
||||
else:
|
||||
# Reset state if connect failed
|
||||
# Connection failed, likely needs password - show password dialog
|
||||
self._reset_connect_state()
|
||||
self._show_password_dialog()
|
||||
|
||||
# Update display after connection attempt
|
||||
self.on_changed()
|
||||
@@ -133,6 +138,67 @@ class WifiNetworkSlot(CenterBox):
|
||||
self.dimage.set_property("style-classes", " ".join(self.styles))
|
||||
return
|
||||
|
||||
def _show_password_dialog(self):
|
||||
"""Show the WiFi password dialog"""
|
||||
# Close the control center first
|
||||
if self.parent and hasattr(self.parent, "hide_controlcenter"):
|
||||
self.parent.hide_controlcenter()
|
||||
|
||||
# Create a new dialog each time to ensure clean state
|
||||
if self.password_dialog:
|
||||
self.password_dialog.destroy_dialog()
|
||||
|
||||
self.password_dialog = WiFiPasswordDialog(
|
||||
ssid=self.ssid,
|
||||
on_connect_callback=self._on_password_connect,
|
||||
on_cancel_callback=self._on_password_cancel,
|
||||
)
|
||||
|
||||
self.password_dialog.show_dialog()
|
||||
|
||||
def _on_password_connect(self, ssid, password):
|
||||
"""Handle password dialog connect action"""
|
||||
if password.strip():
|
||||
# Show connecting state
|
||||
self.dimage.set_property("icon-name", "network-wireless-acquiring-symbolic")
|
||||
self.dimage.add_style_class("connecting")
|
||||
|
||||
# Try to connect with password
|
||||
try:
|
||||
success = self.wifi_service.connect_network(ssid, password)
|
||||
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 - show error in dialog immediately
|
||||
self._reset_connect_state()
|
||||
if self.password_dialog:
|
||||
# Show error immediately without delay
|
||||
self._show_connection_error()
|
||||
except Exception:
|
||||
# Handle any connection errors gracefully
|
||||
self._reset_connect_state()
|
||||
if self.password_dialog:
|
||||
# Show error immediately
|
||||
self._show_connection_error("Connection failed. Please try again.")
|
||||
|
||||
# Update display after connection attempt
|
||||
self.on_changed()
|
||||
|
||||
def _show_connection_error(self, message="Incorrect password. Please try again."):
|
||||
"""Show connection error in a separate thread to prevent UI blocking"""
|
||||
if self.password_dialog:
|
||||
self.password_dialog.show_error(message)
|
||||
return False # Don't repeat if called from GLib.timeout_add
|
||||
|
||||
def _on_password_cancel(self):
|
||||
"""Handle password dialog cancel action"""
|
||||
# Reset any connecting state
|
||||
self._reset_connect_state()
|
||||
|
||||
|
||||
class WifiConnections(Box):
|
||||
def __init__(self, parent, **kwargs):
|
||||
@@ -261,7 +327,9 @@ class WifiConnections(Box):
|
||||
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)
|
||||
network_slot = WifiNetworkSlot(
|
||||
ap_data, self.wifi_service, parent=self.parent
|
||||
)
|
||||
self.available_networks.add(network_slot)
|
||||
|
||||
# Update all network connection states
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.shapes import Corner
|
||||
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -2,6 +2,8 @@ import json
|
||||
import logging
|
||||
|
||||
import cairo
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
|
||||
import config.data as data
|
||||
from fabric.hyprland.widgets import get_hyprland_connection
|
||||
from fabric.utils import (
|
||||
@@ -17,13 +19,11 @@ from fabric.widgets.button import Button
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from gi.repository import Gdk, GLib, Gtk
|
||||
from modules.corners import MyCorner
|
||||
from utils.icon_resolver import IconResolver
|
||||
from utils.occlusion import check_occlusion
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from modules.corners import MyCorner
|
||||
|
||||
# TODO: use modus_service
|
||||
|
||||
|
||||
|
||||
+123
-45
@@ -1,42 +1,35 @@
|
||||
import os
|
||||
import sys
|
||||
from typing import cast
|
||||
|
||||
from fabric import Application
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.button import Button
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from gi.repository import GdkPixbuf, GLib # type: ignore
|
||||
|
||||
from config.data import NOTIFICATION_TIMEOUT
|
||||
from fabric.notifications import (
|
||||
NotificationAction,
|
||||
Notifications,
|
||||
Notification,
|
||||
NotificationAction,
|
||||
NotificationCloseReason,
|
||||
NotificationImagePixmap,
|
||||
Notifications,
|
||||
)
|
||||
import os
|
||||
from fabric.utils import invoke_repeater, get_relative_path
|
||||
|
||||
from fabric.utils import invoke_repeater
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.label import Label
|
||||
from utils.roam import modus_service
|
||||
from widgets.custom_image import CustomImage
|
||||
from widgets.customrevealer import SlideRevealer
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from utils.custom_image import CustomImage
|
||||
|
||||
from gi.repository import GdkPixbuf # type: ignore
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
NOTIFICATION_WIDTH = 360
|
||||
NOTIFICATION_IMAGE_SIZE = 48
|
||||
NOTIFICATION_TIMEOUT = 10 * 1000
|
||||
|
||||
|
||||
# Improved animation smoothness and consistent behavior for rapid notifications.
|
||||
|
||||
def smooth_revealer_animation(revealer: Revealer, duration: int = 300):
|
||||
"""
|
||||
Adjusts the Revealer's animation properties for smoother transitions.
|
||||
"""
|
||||
revealer.set_property("transition-duration", duration)
|
||||
revealer.set_property("transition-type", "slide-left")
|
||||
def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 250):
|
||||
revealer.duration = duration
|
||||
|
||||
|
||||
def get_notification_image_pixbuf(
|
||||
@@ -74,7 +67,6 @@ class NotificationWidget(Box):
|
||||
self.image = notification.app_icon
|
||||
body_container = Box(name="noti-image", spacing=4, orientation="h")
|
||||
|
||||
# Load and show image (from app_icon, image_file, or pixbuf)
|
||||
pixbuf = get_notification_image_pixbuf(notification)
|
||||
image_file = None
|
||||
|
||||
@@ -129,7 +121,6 @@ class NotificationWidget(Box):
|
||||
),
|
||||
Label(
|
||||
label=self._notification.body, # type: ignore
|
||||
# line_wrap="word-char",
|
||||
v_align="start",
|
||||
h_align="start",
|
||||
ellipsization="end",
|
||||
@@ -187,39 +178,103 @@ class NotificationWidget(Box):
|
||||
self.destroy()
|
||||
|
||||
|
||||
class NotificationRevealer(Revealer):
|
||||
class NotificationRevealer(SlideRevealer):
|
||||
def __init__(self, notification: Notification, on_transition_end=None, **kwargs):
|
||||
self.notif_box = NotificationWidget(notification)
|
||||
self.notification = notification
|
||||
self.on_transition_end = on_transition_end
|
||||
super().__init__(
|
||||
child=Box(
|
||||
children=[self.notif_box],
|
||||
),
|
||||
transition_duration=300,
|
||||
transition_type="slide-left",
|
||||
|
||||
# Add swipe detection variables
|
||||
self._drag_start_x = 0
|
||||
self._drag_start_y = 0
|
||||
self._is_dragging = False
|
||||
self._swipe_threshold = 100 # pixels to trigger swipe dismiss
|
||||
|
||||
# Wrap notification in EventBox for swipe detection
|
||||
self.event_box = EventBox(
|
||||
events=[
|
||||
"button-press-event",
|
||||
"button-release-event",
|
||||
"motion-notify-event",
|
||||
],
|
||||
child=self.notif_box,
|
||||
)
|
||||
|
||||
self.connect(
|
||||
"notify::child-revealed",
|
||||
self._on_child_revealed,
|
||||
self.event_box.connect("button-press-event", self._on_button_press)
|
||||
self.event_box.connect("button-release-event", self._on_button_release)
|
||||
self.event_box.connect("motion-notify-event", self._on_motion)
|
||||
|
||||
super().__init__(
|
||||
child=self.event_box,
|
||||
direction="right",
|
||||
duration=250,
|
||||
)
|
||||
|
||||
smooth_revealer_animation(self)
|
||||
|
||||
# Disconnect the destroy_noti handler from the notification widget
|
||||
# to prevent immediate destruction that interferes with slide animation
|
||||
self.notification.disconnect_by_func(self.notif_box.destroy_noti)
|
||||
|
||||
# Connect our own handler that manages the slide animation
|
||||
self.notification.connect("closed", self.on_resolved)
|
||||
|
||||
def _on_child_revealed(self, *args):
|
||||
if not self.get_child_revealed():
|
||||
def _on_animation_complete(self, is_hiding=False):
|
||||
if is_hiding:
|
||||
# Manually destroy the notification widget since we disconnected its handler
|
||||
self.notif_box.destroy_noti()
|
||||
|
||||
if self.on_transition_end:
|
||||
self.on_transition_end()
|
||||
self.destroy()
|
||||
|
||||
def on_resolved(
|
||||
self,
|
||||
notification: Notification,
|
||||
_notification: Notification,
|
||||
reason: NotificationCloseReason,
|
||||
):
|
||||
self.set_property("transition-type", "slide-right")
|
||||
self.set_reveal_child(False)
|
||||
# Use left-to-right slide for auto-dismiss (expired), right-to-left for manual close
|
||||
if reason == "expired":
|
||||
# Left-to-right slide for auto-dismiss
|
||||
self.set_slide_direction("left")
|
||||
else:
|
||||
# Right-to-left slide for manual close
|
||||
self.set_slide_direction("right")
|
||||
|
||||
self.hide()
|
||||
GLib.timeout_add(self.duration + 50, lambda: self._on_animation_complete(True))
|
||||
|
||||
def _on_button_press(self, _widget, event):
|
||||
if event.button == 1:
|
||||
self._drag_start_x = event.x
|
||||
self._drag_start_y = event.y
|
||||
self._is_dragging = True
|
||||
return False
|
||||
|
||||
def _on_button_release(self, _widget, event):
|
||||
if self._is_dragging and event.button == 1:
|
||||
self._is_dragging = False
|
||||
|
||||
# Calculate swipe distance
|
||||
dx = event.x - self._drag_start_x
|
||||
dy = abs(event.y - self._drag_start_y)
|
||||
|
||||
if dx > self._swipe_threshold and dy < 50: # 50px vertical tolerance
|
||||
# Follow the swipe gesture direction
|
||||
self.set_slide_direction("right")
|
||||
self.hide()
|
||||
# Schedule cleanup after animation
|
||||
GLib.timeout_add(
|
||||
self.duration + 50, lambda: self._on_animation_complete(True)
|
||||
)
|
||||
return False
|
||||
|
||||
def _on_motion(self, _widget, event):
|
||||
if self._is_dragging:
|
||||
# TODO: Add visual feedback during swipe
|
||||
# Could add translation or opacity changes here
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
class ModusNoti(Window):
|
||||
@@ -244,6 +299,29 @@ class ModusNoti(Window):
|
||||
|
||||
def on_new_notification(self, fabric_notif, id):
|
||||
notification: Notification = fabric_notif.get_notification_from_id(id)
|
||||
new_box = NotificationRevealer(notification, on_transition_end=lambda: self.notifications.remove(new_box))
|
||||
self.notifications.add(new_box)
|
||||
new_box.set_reveal_child(True)
|
||||
|
||||
# Check if Do Not Disturb is enabled
|
||||
if modus_service.dont_disturb:
|
||||
# Still cache the notification for notification center, but don't show it
|
||||
modus_service.cache_notification(notification)
|
||||
# Close the notification immediately to prevent it from showing
|
||||
notification.close("dismissed-by-user")
|
||||
return
|
||||
|
||||
new_box = NotificationRevealer(
|
||||
notification, on_transition_end=lambda: self.notifications.remove(new_box)
|
||||
)
|
||||
|
||||
# Insert at the beginning (index 0) to show new notifications at the top
|
||||
current_children = list(self.notifications.children)
|
||||
current_children.insert(0, new_box)
|
||||
self.notifications.children = current_children
|
||||
|
||||
|
||||
# This ensures each notification's container is fully laid out before animation
|
||||
def start_animation():
|
||||
if new_box.get_parent(): # Only animate if still in the tree
|
||||
new_box.reveal()
|
||||
return False
|
||||
|
||||
GLib.timeout_add(32, start_animation)
|
||||
+5
-5
@@ -1,16 +1,15 @@
|
||||
import math
|
||||
import time
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import utils.icons as icons
|
||||
from gi.repository import GLib, GObject
|
||||
|
||||
from fabric.audio import Audio
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from fabric.widgets.scale import Scale, ScaleMark
|
||||
from fabric.widgets.svg import Svg
|
||||
from fabric.utils.helpers import get_relative_path
|
||||
from gi.repository import GLib, GObject
|
||||
import math
|
||||
from services.brightness import Brightness
|
||||
from utils.animator import Animator
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
@@ -410,6 +409,7 @@ class OSD(Window):
|
||||
super().__init__(
|
||||
layer="overlay",
|
||||
anchor="bottom",
|
||||
title="modus",
|
||||
child=self.main_box,
|
||||
visible=False,
|
||||
pass_through=True,
|
||||
|
||||
@@ -6,9 +6,10 @@ including fallback mechanisms for file paths and common icon locations.
|
||||
"""
|
||||
|
||||
import os
|
||||
from gi.repository import Gtk, GdkPixbuf
|
||||
from fabric.system_tray.widgets import SystemTrayItem
|
||||
|
||||
from gi.repository import GdkPixbuf, Gtk
|
||||
|
||||
from fabric.system_tray.widgets import SystemTrayItem
|
||||
|
||||
# FIX: the tooltip should show application names instead of unknown
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from fabric.utils import get_relative_path
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.svg import Svg
|
||||
from services.network import NetworkService
|
||||
from services.battery import Battery
|
||||
from services.network import NetworkService
|
||||
from utils.roam import modus_service
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ class BluetoothIndicator(Box):
|
||||
self.bt_icon = Svg(
|
||||
name="bt-icon",
|
||||
size=20,
|
||||
svg_file=get_relative_path("../../../config/assets/icons/applets/bluetooth-clear.svg"),
|
||||
svg_file=get_relative_path(
|
||||
"../../../config/assets/icons/applets/bluetooth-clear.svg"
|
||||
),
|
||||
)
|
||||
|
||||
self.bt_button = Button(name="bt-button", child=self.bt_icon)
|
||||
@@ -34,14 +36,18 @@ class BluetoothIndicator(Box):
|
||||
def update_state(self):
|
||||
if not self.bluetooth.enabled:
|
||||
self.bt_icon.set_from_file(
|
||||
get_relative_path("../../../config/assets/icons/applets/bluetooth-off-clear.svg")
|
||||
get_relative_path(
|
||||
"../../../config/assets/icons/applets/bluetooth-off-clear.svg"
|
||||
)
|
||||
)
|
||||
tooltip = "Bluetooth disabled"
|
||||
else:
|
||||
connected_devices = self.bluetooth.connected_devices
|
||||
if connected_devices:
|
||||
self.bt_icon.set_from_file(
|
||||
get_relative_path("../../../config/assets/icons/applets/bluetooth-clear.svg")
|
||||
get_relative_path(
|
||||
"../../../config/assets/icons/applets/bluetooth-clear.svg"
|
||||
)
|
||||
)
|
||||
if len(connected_devices) == 1:
|
||||
device = connected_devices[0]
|
||||
@@ -52,7 +58,9 @@ class BluetoothIndicator(Box):
|
||||
tooltip = f"Connected to {len(connected_devices)} devices"
|
||||
else:
|
||||
self.bt_icon.set_from_file(
|
||||
get_relative_path("../../../config/assets/icons/applets/bluetooth-clear.svg")
|
||||
get_relative_path(
|
||||
"../../../config/assets/icons/applets/bluetooth-clear.svg"
|
||||
)
|
||||
)
|
||||
tooltip = "No devices connected"
|
||||
|
||||
@@ -104,7 +112,9 @@ class NetworkIndicator(Box):
|
||||
self.network_icon = Svg(
|
||||
name="network-icon",
|
||||
size=22,
|
||||
svg_file=get_relative_path("../../../config/assets/icons/applets/wifi-clear.svg"),
|
||||
svg_file=get_relative_path(
|
||||
"../../../config/assets/icons/applets/wifi-clear.svg"
|
||||
),
|
||||
)
|
||||
|
||||
self.network_button = Button(name="network-button", child=self.network_icon)
|
||||
@@ -227,7 +237,9 @@ class BatteryIndicator(Box):
|
||||
self.battery_icon = Svg(
|
||||
name="battery-icon",
|
||||
size=22,
|
||||
svg_file=get_relative_path("../../../config/assets/icons/battery/battery-100.svg"),
|
||||
svg_file=get_relative_path(
|
||||
"../../../config/assets/icons/battery/battery-100.svg"
|
||||
),
|
||||
)
|
||||
|
||||
self.battery_button = Button(name="battery-button", child=self.battery_icon)
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 modules.about import About
|
||||
from utils.roam import modus_service
|
||||
from widgets.dropdown import ModusDropdown, dropdown_divider
|
||||
|
||||
@@ -4,22 +4,19 @@ from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.label import Label
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from fabric.widgets.svg import Svg
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
from modules.controlcenter.main import ModusControlCenter
|
||||
from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray
|
||||
from modules.panel.components.indicators import (
|
||||
BatteryIndicator,
|
||||
BluetoothIndicator,
|
||||
NetworkIndicator,
|
||||
BatteryIndicator,
|
||||
)
|
||||
from modules.panel.components.menubar import MenuBar
|
||||
from modules.panel.components.recording_indicator import RecordingIndicator
|
||||
|
||||
from modules.controlcenter.main import ModusControlCenter
|
||||
from widgets.mousecapture import MouseCapture
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
# Apply enhanced system tray icon handling
|
||||
apply_enhanced_system_tray()
|
||||
|
||||
+2
-1
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
|
||||
from gi.repository import Gdk
|
||||
|
||||
import config.data as data
|
||||
from fabric.hyprland.widgets import get_hyprland_connection
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gdk
|
||||
from utils.icon_resolver import IconResolver
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
from fabric.core import Property, Service, Signal
|
||||
from pydbus import SystemBus
|
||||
import psutil
|
||||
from gi.repository import GLib
|
||||
from pydbus import SystemBus
|
||||
|
||||
from fabric.core import Property, Service, Signal
|
||||
|
||||
DeviceState = {
|
||||
0: "UNKNOWN",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
|
||||
from gi.repository import GLib
|
||||
from loguru import logger
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import exec_shell_command_async, monitor_file
|
||||
from gi.repository import GLib
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def exec_brightnessctl_async(args: str):
|
||||
|
||||
+5
-3
@@ -1,8 +1,10 @@
|
||||
from loguru import logger
|
||||
from fabric.core.service import Service, Signal, Property
|
||||
from fabric.notifications import Notification
|
||||
from typing import List
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.notifications import Notification
|
||||
|
||||
|
||||
class ModusService(Service):
|
||||
@Signal
|
||||
|
||||
+4
-7
@@ -1,11 +1,9 @@
|
||||
from gi.repository import Playerctl, GLib
|
||||
from fabric.utils import bulk_connect
|
||||
from fabric.core.service import Service, Signal
|
||||
from fabric import Fabricator
|
||||
import gi
|
||||
from gi.repository import GLib, Playerctl
|
||||
|
||||
from config.data import ALLOWED_PLAYERS
|
||||
|
||||
import gi
|
||||
from fabric import Fabricator
|
||||
from fabric.core.service import Service, Signal
|
||||
|
||||
gi.require_version("Playerctl", "2.0")
|
||||
|
||||
@@ -158,7 +156,6 @@ class PlayerService(Service):
|
||||
and hasattr(self._player.props, "metadata")
|
||||
and self._player.props.metadata
|
||||
):
|
||||
|
||||
metadata = self._player.props.metadata
|
||||
|
||||
# Try mpris:length first (most common)
|
||||
|
||||
+386
-30
@@ -1,12 +1,16 @@
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, List, Literal
|
||||
|
||||
import gi
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import bulk_connect
|
||||
from gi.repository import Gio
|
||||
from loguru import logger
|
||||
|
||||
from fabric.core.service import Property, Service, Signal
|
||||
from fabric.utils import bulk_connect
|
||||
|
||||
# FIX: HAHA Could be better if loc is less
|
||||
|
||||
try:
|
||||
gi.require_version("NM", "1.0")
|
||||
from gi.repository import NM
|
||||
@@ -190,31 +194,285 @@ class Wifi(Service):
|
||||
if not ssid:
|
||||
logger.error("[NetworkService] SSID cannot be empty")
|
||||
return False
|
||||
|
||||
# Check if nm-applet is running before we kill it
|
||||
nm_applet_was_running = self._is_nm_applet_running()
|
||||
|
||||
# Kill any running NetworkManager authentication agents to prevent GUI dialogs
|
||||
try:
|
||||
# First try to connect using saved connection
|
||||
subprocess.run(["pkill", "-f", "nm-applet"], capture_output=True)
|
||||
subprocess.run(["pkill", "-f", "nm-connection-editor"], capture_output=True)
|
||||
subprocess.run(
|
||||
["pkill", "-f", "polkit-gnome-authentication-agent"],
|
||||
capture_output=True,
|
||||
)
|
||||
except:
|
||||
pass # Ignore errors if processes don't exist
|
||||
|
||||
try:
|
||||
# First try to connect using saved connection (suppress GUI)
|
||||
try:
|
||||
subprocess.run(["nmcli", "con", "up", ssid], check=True)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"DISPLAY": "",
|
||||
"WAYLAND_DISPLAY": "",
|
||||
"XDG_SESSION_TYPE": "tty",
|
||||
}
|
||||
)
|
||||
subprocess.run(
|
||||
["nmcli", "--terse", "con", "up", ssid],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
# If saved connection fails, try with password if provided
|
||||
if password:
|
||||
cmd = [
|
||||
# Create environment without GUI components to prevent dialogs
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"DISPLAY": "", # No X11 display
|
||||
"WAYLAND_DISPLAY": "", # No Wayland display
|
||||
"XDG_SESSION_TYPE": "tty", # Force TTY session
|
||||
"DESKTOP_SESSION": "", # No desktop session
|
||||
"NM_EDITOR": "/bin/false", # Disable editor
|
||||
"NM_POLKIT_AGENT": "/bin/false", # Disable polkit agent
|
||||
}
|
||||
)
|
||||
|
||||
# First, try to delete any existing connection with the same name to avoid conflicts
|
||||
try:
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", ssid],
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
except:
|
||||
pass # Ignore if connection doesn't exist
|
||||
|
||||
# Create a temporary connection profile with the password
|
||||
# Use a unique temporary name to avoid conflicts
|
||||
temp_connection_name = f"temp_{ssid}_{os.getpid()}"
|
||||
|
||||
create_cmd = [
|
||||
"nmcli",
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
temp_connection_name,
|
||||
"ssid",
|
||||
ssid,
|
||||
"wifi-sec.key-mgmt",
|
||||
"wpa-psk",
|
||||
"wifi-sec.psk",
|
||||
password,
|
||||
"connection.autoconnect",
|
||||
"no", # Don't auto-connect
|
||||
]
|
||||
|
||||
create_result = subprocess.run(
|
||||
create_cmd, capture_output=True, text=True, env=env
|
||||
)
|
||||
|
||||
if create_result.returncode == 0:
|
||||
# Try to activate the temporary connection with shorter timeout
|
||||
activate_cmd = [
|
||||
"nmcli",
|
||||
"--wait",
|
||||
"3",
|
||||
"connection",
|
||||
"up",
|
||||
temp_connection_name,
|
||||
]
|
||||
try:
|
||||
activate_result = subprocess.run(
|
||||
activate_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=5, # 5 second timeout for faster failure detection
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Connection timed out - treat as failure
|
||||
activate_result = subprocess.CompletedProcess(
|
||||
activate_cmd, 1, "", "Connection timeout"
|
||||
)
|
||||
|
||||
if activate_result.returncode == 0:
|
||||
# Connection successful!
|
||||
if remember:
|
||||
# Rename the temporary connection to the final name
|
||||
rename_cmd = [
|
||||
"nmcli",
|
||||
"connection",
|
||||
"modify",
|
||||
temp_connection_name,
|
||||
"connection.id",
|
||||
ssid,
|
||||
"connection.autoconnect",
|
||||
"yes",
|
||||
]
|
||||
subprocess.run(rename_cmd, capture_output=True, env=env)
|
||||
else:
|
||||
# Don't remember - delete the temporary connection
|
||||
subprocess.run(
|
||||
[
|
||||
"nmcli",
|
||||
"connection",
|
||||
"delete",
|
||||
temp_connection_name,
|
||||
],
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# Connection failed - delete the temporary connection immediately
|
||||
# This ensures incorrect passwords are never saved
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", temp_connection_name],
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
# Failed to create connection profile
|
||||
return False
|
||||
|
||||
# Fallback: try the old method but always use temporary connections first
|
||||
# This ensures incorrect passwords are never permanently saved
|
||||
fallback_cmd = [
|
||||
"nmcli",
|
||||
"--terse",
|
||||
"--wait",
|
||||
"3", # Reduced wait time for faster response
|
||||
"device",
|
||||
"wifi",
|
||||
"connect",
|
||||
ssid,
|
||||
"password",
|
||||
password,
|
||||
"--temporary", # Always use temporary for initial attempt
|
||||
]
|
||||
if not remember:
|
||||
cmd.extend(["--temporary"])
|
||||
subprocess.run(cmd, check=True)
|
||||
return True
|
||||
|
||||
# Set additional environment variables to suppress dialogs
|
||||
env.update(
|
||||
{
|
||||
"SSH_ASKPASS": "/bin/false",
|
||||
"GIT_ASKPASS": "/bin/false",
|
||||
"SUDO_ASKPASS": "/bin/false",
|
||||
"NM_POLKIT_AGENT": "",
|
||||
}
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
fallback_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# If connection succeeded and we want to remember it
|
||||
if result.returncode == 0 and remember:
|
||||
# Create a permanent connection profile
|
||||
save_cmd = [
|
||||
"nmcli",
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
ssid,
|
||||
"ssid",
|
||||
ssid,
|
||||
"wifi-sec.key-mgmt",
|
||||
"wpa-psk",
|
||||
"wifi-sec.psk",
|
||||
password,
|
||||
"connection.autoconnect",
|
||||
"yes",
|
||||
]
|
||||
subprocess.run(save_cmd, capture_output=True, env=env)
|
||||
|
||||
return result.returncode == 0
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"[NetworkService] Failed connecting to network: {e}")
|
||||
return False
|
||||
finally:
|
||||
# Cleanup: Remove any leftover temporary connections for this process
|
||||
self._cleanup_temp_connections()
|
||||
|
||||
# Restart nm-applet if it was running before we killed it
|
||||
if nm_applet_was_running:
|
||||
self._restart_nm_applet()
|
||||
|
||||
def _is_nm_applet_running(self):
|
||||
"""Check if nm-applet is currently running"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "nm-applet"], capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _restart_nm_applet(self):
|
||||
"""Restart nm-applet with indicator support"""
|
||||
try:
|
||||
# Small delay to ensure connection process is complete
|
||||
import time
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Start nm-applet in the background with indicator support
|
||||
subprocess.Popen(
|
||||
["nm-applet", "--indicator"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, # Detach from parent process
|
||||
)
|
||||
logger.debug("[NetworkService] Restarted nm-applet with indicator support")
|
||||
except Exception as e:
|
||||
logger.debug(f"[NetworkService] Failed to restart nm-applet: {e}")
|
||||
|
||||
def _cleanup_temp_connections(self):
|
||||
"""Clean up any temporary connections created by this process"""
|
||||
try:
|
||||
# List all connections and find temporary ones created by this process
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
connections = result.stdout.strip().split("\n")
|
||||
temp_prefix = "temp_"
|
||||
process_suffix = f"_{os.getpid()}"
|
||||
|
||||
for connection in connections:
|
||||
if connection.startswith(temp_prefix) and connection.endswith(
|
||||
process_suffix
|
||||
):
|
||||
# Delete this temporary connection
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", connection],
|
||||
capture_output=True,
|
||||
)
|
||||
logger.debug(
|
||||
f"[NetworkService] Cleaned up temporary connection: {
|
||||
connection
|
||||
}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[NetworkService] Error during temp connection cleanup: {e}")
|
||||
|
||||
def disconnect_network(self, ssid: str) -> bool:
|
||||
"""Disconnect from a WiFi network"""
|
||||
@@ -227,46 +485,67 @@ class Wifi(Service):
|
||||
if self._device and self._device.get_active_connection():
|
||||
active_connection = self._device.get_active_connection()
|
||||
# Verify this is the connection we want to disconnect
|
||||
if self._ap and NM.utils_ssid_to_utf8(self._ap.get_ssid().get_data()) == ssid:
|
||||
if (
|
||||
self._ap
|
||||
and NM.utils_ssid_to_utf8(self._ap.get_ssid().get_data()) == ssid
|
||||
):
|
||||
try:
|
||||
# Use NetworkManager API to deactivate the connection
|
||||
self._client.deactivate_connection_async(
|
||||
active_connection,
|
||||
None,
|
||||
lambda client, result: self._disconnect_finished(client, result, ssid)
|
||||
lambda client, result: self._disconnect_finished(
|
||||
client, result, ssid
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"[NetworkService] Initiated disconnect from {ssid}"
|
||||
)
|
||||
logger.info(f"[NetworkService] Initiated disconnect from {ssid}")
|
||||
|
||||
# Force immediate state update to reflect disconnection
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.timeout_add(100, lambda: self._force_state_update())
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[NetworkService] API disconnect failed: {e}, trying nmcli")
|
||||
logger.warning(
|
||||
f"[NetworkService] API disconnect failed: {e}, trying nmcli"
|
||||
)
|
||||
|
||||
# Method 2: Fallback to nmcli with proper connection ID resolution
|
||||
try:
|
||||
# First, get the connection ID that matches the SSID
|
||||
result = subprocess.check_output(
|
||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"],
|
||||
text=True
|
||||
[
|
||||
"nmcli",
|
||||
"-t",
|
||||
"-f",
|
||||
"NAME,TYPE",
|
||||
"connection",
|
||||
"show",
|
||||
"--active",
|
||||
],
|
||||
text=True,
|
||||
)
|
||||
|
||||
connection_id = None
|
||||
for line in result.strip().split('\n'):
|
||||
for line in result.strip().split("\n"):
|
||||
if line:
|
||||
name, conn_type = line.split(':', 1)
|
||||
name, conn_type = line.split(":", 1)
|
||||
if conn_type == "802-11-wireless" and name == ssid:
|
||||
connection_id = name
|
||||
break
|
||||
|
||||
if connection_id:
|
||||
subprocess.run(["nmcli", "con", "down", connection_id], check=True)
|
||||
logger.info(f"[NetworkService] Disconnected from {ssid} using connection ID")
|
||||
logger.info(
|
||||
f"[NetworkService] Disconnected from {ssid} using connection ID"
|
||||
)
|
||||
|
||||
# Force immediate state update
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.timeout_add(100, lambda: self._force_state_update())
|
||||
|
||||
return True
|
||||
@@ -274,16 +553,25 @@ class Wifi(Service):
|
||||
# Method 3: Try disconnecting by device
|
||||
device_name = self._device.get_iface() if self._device else None
|
||||
if device_name:
|
||||
subprocess.run(["nmcli", "device", "disconnect", device_name], check=True)
|
||||
logger.info(f"[NetworkService] Disconnected device {device_name}")
|
||||
subprocess.run(
|
||||
["nmcli", "device", "disconnect", device_name], check=True
|
||||
)
|
||||
logger.info(
|
||||
f"[NetworkService] Disconnected device {device_name}"
|
||||
)
|
||||
|
||||
# Force immediate state update
|
||||
from gi.repository import GLib
|
||||
|
||||
GLib.timeout_add(100, lambda: self._force_state_update())
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[NetworkService] No active connection found for SSID: {ssid}")
|
||||
logger.error(
|
||||
f"[NetworkService] No active connection found for SSID: {
|
||||
ssid
|
||||
}"
|
||||
)
|
||||
return False
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -371,9 +659,11 @@ class Wifi(Service):
|
||||
return {
|
||||
"bssid": ap.get_bssid(),
|
||||
"last_seen": ap.get_last_seen(),
|
||||
"ssid": NM.utils_ssid_to_utf8(ap.get_ssid().get_data())
|
||||
if ap.get_ssid()
|
||||
else "Unknown",
|
||||
"ssid": (
|
||||
NM.utils_ssid_to_utf8(ap.get_ssid().get_data())
|
||||
if ap.get_ssid()
|
||||
else "Unknown"
|
||||
),
|
||||
"active-ap": self._ap,
|
||||
"strength": ap.get_strength(),
|
||||
"frequency": ap.get_frequency(),
|
||||
@@ -397,7 +687,7 @@ class Wifi(Service):
|
||||
active_connection = self._device.get_active_connection()
|
||||
if not active_connection or active_connection.get_state() in [
|
||||
NM.ActiveConnectionState.DEACTIVATED,
|
||||
NM.ActiveConnectionState.DEACTIVATING
|
||||
NM.ActiveConnectionState.DEACTIVATING,
|
||||
]:
|
||||
return "Disconnected"
|
||||
|
||||
@@ -502,6 +792,7 @@ class NetworkService(Service):
|
||||
self.wifi_device: Wifi | None = None
|
||||
self.ethernet_device: Ethernet | None = None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
NM.Client.new_async(
|
||||
cancellable=None,
|
||||
callback=self._init_network_client,
|
||||
@@ -517,6 +808,8 @@ class NetworkService(Service):
|
||||
|
||||
if wifi_device:
|
||||
self.wifi_device = Wifi(self._client, wifi_device)
|
||||
# Clean up any leftover temporary connections from previous runs
|
||||
self._cleanup_temp_connections()
|
||||
self.emit("device-ready")
|
||||
|
||||
if ethernet_device:
|
||||
@@ -547,12 +840,75 @@ class NetworkService(Service):
|
||||
"wifi"
|
||||
if "wireless"
|
||||
in str(self._client.get_primary_connection().get_connection_type())
|
||||
else "wired"
|
||||
if "ethernet"
|
||||
in str(self._client.get_primary_connection().get_connection_type())
|
||||
else None
|
||||
else (
|
||||
"wired"
|
||||
if "ethernet"
|
||||
in str(self._client.get_primary_connection().get_connection_type())
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
@Property(str, "readable")
|
||||
def primary_device(self) -> Literal["wifi", "wired"] | None:
|
||||
return self._get_primary_device()
|
||||
return self._get_primary_device()
|
||||
|
||||
def _is_nm_applet_running(self):
|
||||
"""Check if nm-applet is currently running"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "nm-applet"], capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _restart_nm_applet(self):
|
||||
"""Restart nm-applet with indicator support"""
|
||||
try:
|
||||
# Small delay to ensure connection process is complete
|
||||
import time
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
# Start nm-applet in the background with indicator support
|
||||
subprocess.Popen(
|
||||
["nm-applet", "--indicator"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, # Detach from parent process
|
||||
)
|
||||
logger.debug("[NetworkService] Restarted nm-applet with indicator support")
|
||||
except Exception as e:
|
||||
logger.debug(f"[NetworkService] Failed to restart nm-applet: {e}")
|
||||
|
||||
def _cleanup_temp_connections(self):
|
||||
"""Clean up any temporary connections created by this process"""
|
||||
try:
|
||||
# List all connections and find temporary ones created by this process
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
connections = result.stdout.strip().split("\n")
|
||||
temp_prefix = "temp_"
|
||||
process_suffix = f"_{os.getpid()}"
|
||||
|
||||
for connection in connections:
|
||||
if connection.startswith(temp_prefix) and connection.endswith(
|
||||
process_suffix
|
||||
):
|
||||
# Delete this temporary connection
|
||||
subprocess.run(
|
||||
["nmcli", "connection", "delete", connection],
|
||||
capture_output=True,
|
||||
)
|
||||
logger.debug(
|
||||
f"[NetworkService] Cleaned up temporary connection: {
|
||||
connection
|
||||
}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[NetworkService] Error during temp connection cleanup: {e}")
|
||||
+139
-43
@@ -19,26 +19,10 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#focus-widget {
|
||||
padding: 10px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
#bluetooth-icon,
|
||||
#wifi-icon {
|
||||
background-color: #666666;
|
||||
padding: 8px;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
border-radius: 50%;
|
||||
color: #ffffff;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
#bluetooth-icon.connected,
|
||||
#wifi-icon.connected {
|
||||
.icon {
|
||||
background-color: #2369ff;
|
||||
opacity: 1;
|
||||
font-size: 15px;
|
||||
padding: 20px 20px;
|
||||
}
|
||||
|
||||
#bluetooth-widget-label,
|
||||
@@ -49,6 +33,140 @@
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* WiFi Password Dialog */
|
||||
#wifi-password-dialog {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#wifi-dialog-background {
|
||||
background-color: rgba(40, 40, 40, 0.95);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 20px;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
#wifi-dialog-title-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#wifi-dialog-icon {
|
||||
color: #007aff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
#wifi-dialog-title {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#wifi-dialog-error {
|
||||
color: #ff4444;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#wifi-dialog-password-container {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
#wifi-dialog-password-label {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#wifi-dialog-password-entry {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#wifi-dialog-password-entry:focus {
|
||||
border-color: #007aff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.3);
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
#wifi-dialog-show-password-box {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#wifi-dialog-show-password-button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
#wifi-dialog-show-password-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#wifi-dialog-show-password-button image {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#wifi-dialog-show-password-label {
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#wifi-dialog-button-box {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#wifi-dialog-cancel-button,
|
||||
#wifi-dialog-join-button {
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 80px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
#wifi-dialog-cancel-button {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#wifi-dialog-cancel-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
#wifi-dialog-join-button {
|
||||
background-color: #007aff;
|
||||
border: 1px solid #007aff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#wifi-dialog-join-button:hover {
|
||||
background-color: #0056cc;
|
||||
border-color: #0056cc;
|
||||
}
|
||||
|
||||
#wifi-dialog-join-button.disabled {
|
||||
opacity: 0.5;
|
||||
background-color: #555555;
|
||||
border-color: #555555;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
#wifi-dialog-join-button.disabled:hover {
|
||||
background-color: #555555;
|
||||
border-color: #555555;
|
||||
color: #aaaaaa;
|
||||
}
|
||||
|
||||
#bluetooth-widget-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
@@ -81,28 +199,6 @@
|
||||
background-color: #2369ff;
|
||||
}
|
||||
|
||||
#device-icon.connecting {
|
||||
background-color: #ff9500;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
#device-icon.disconnecting {
|
||||
background-color: #ff3b30;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
#toggle-button {
|
||||
min-width: 40px;
|
||||
min-height: 20px;
|
||||
@@ -111,13 +207,13 @@
|
||||
padding: 2px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
#toggle-button slider {
|
||||
background-color: var(--primary);
|
||||
border-radius: 16px;
|
||||
min-width: 16px;
|
||||
min-height: 8px;
|
||||
transition:
|
||||
background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25),
|
||||
transition: background-color 0.1s cubic-bezier(0.5, 0.25, 0, 1.25),
|
||||
transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
inset 0 0 0 0.5px alpha(#aaa, 0.4);
|
||||
border-radius: 20px;
|
||||
margin: 4px;
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
#notification .summary {
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
from typing import cast
|
||||
|
||||
from fabric import Property, Service, Signal
|
||||
from gi.repository import GLib, Gtk
|
||||
|
||||
from fabric import Property, Service, Signal
|
||||
|
||||
|
||||
class Animator(Service):
|
||||
@Signal
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
from fabric.core.service import Property
|
||||
from fabric.widgets.widget import Widget
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, GdkPixbuf, Gtk # noqa: E402
|
||||
|
||||
|
||||
class CircleImage(Gtk.DrawingArea, Widget):
|
||||
"""A widget that displays an image in a circular shape with a 1:1 aspect ratio."""
|
||||
|
||||
@Property(int, "read-write")
|
||||
def angle(self) -> int:
|
||||
return self._angle
|
||||
|
||||
@angle.setter
|
||||
def angle(self, value: int):
|
||||
self._angle = value % 360
|
||||
self.queue_draw()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_file: str | None = None,
|
||||
pixbuf: GdkPixbuf.Pixbuf | None = None,
|
||||
name: str | None = None,
|
||||
visible: bool = True,
|
||||
all_visible: bool = False,
|
||||
style: str | None = None,
|
||||
tooltip_text: str | None = None,
|
||||
tooltip_markup: str | None = None,
|
||||
h_align: (
|
||||
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
|
||||
) = None,
|
||||
v_align: (
|
||||
Literal["fill", "start", "end", "center", "baseline"] | Gtk.Align | None
|
||||
) = None,
|
||||
h_expand: bool = False,
|
||||
v_expand: bool = False,
|
||||
size: int | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
Gtk.DrawingArea.__init__(self)
|
||||
Widget.__init__(
|
||||
self,
|
||||
name=name,
|
||||
visible=visible,
|
||||
all_visible=all_visible,
|
||||
style=style,
|
||||
tooltip_text=tooltip_text,
|
||||
tooltip_markup=tooltip_markup,
|
||||
h_align=h_align,
|
||||
v_align=v_align,
|
||||
h_expand=h_expand,
|
||||
v_expand=v_expand,
|
||||
size=size,
|
||||
**kwargs,
|
||||
)
|
||||
self.size = size if size is not None else 100 # Default size if not provided
|
||||
self._angle = 0
|
||||
self._orig_image: GdkPixbuf.Pixbuf | None = (
|
||||
None # Original image for reprocessing
|
||||
)
|
||||
self._image: GdkPixbuf.Pixbuf | None = None
|
||||
if image_file:
|
||||
pix = GdkPixbuf.Pixbuf.new_from_file(image_file)
|
||||
self._orig_image = pix
|
||||
self._image = self._process_image(pix)
|
||||
elif pixbuf:
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.connect("draw", self.on_draw)
|
||||
|
||||
def _process_image(self, pixbuf: GdkPixbuf.Pixbuf) -> GdkPixbuf.Pixbuf:
|
||||
"""Crop the image to a centered square and scale it to the widget’s size."""
|
||||
width, height = pixbuf.get_width(), pixbuf.get_height()
|
||||
if width != height:
|
||||
square_size = min(width, height)
|
||||
x_offset = (width - square_size) // 2
|
||||
y_offset = (height - square_size) // 2
|
||||
pixbuf = pixbuf.new_subpixbuf(x_offset, y_offset, square_size, square_size)
|
||||
else:
|
||||
square_size = width
|
||||
if square_size != self.size:
|
||||
pixbuf = pixbuf.scale_simple(
|
||||
self.size, self.size, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
return pixbuf
|
||||
|
||||
def on_draw(self, widget: "CircleImage", ctx: cairo.Context):
|
||||
if self._image:
|
||||
ctx.save()
|
||||
# Create a circular clipping path
|
||||
ctx.arc(self.size / 2, self.size / 2, self.size / 2, 0, 2 * math.pi)
|
||||
ctx.clip()
|
||||
# Rotate around the center of the square image
|
||||
ctx.translate(self.size / 2, self.size / 2)
|
||||
ctx.rotate(self._angle * math.pi / 180.0)
|
||||
ctx.translate(-self.size / 2, -self.size / 2)
|
||||
Gdk.cairo_set_source_pixbuf(ctx, self._image, 0, 0)
|
||||
ctx.paint()
|
||||
ctx.restore()
|
||||
|
||||
def set_image_from_file(self, new_image_file: str):
|
||||
if not new_image_file:
|
||||
return
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file(new_image_file)
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.queue_draw()
|
||||
|
||||
def set_image_from_pixbuf(self, pixbuf: GdkPixbuf.Pixbuf):
|
||||
if not pixbuf:
|
||||
return
|
||||
self._orig_image = pixbuf
|
||||
self._image = self._process_image(pixbuf)
|
||||
self.queue_draw()
|
||||
|
||||
def set_image_size(self, size: int):
|
||||
self.size = size
|
||||
if self._orig_image:
|
||||
self._image = self._process_image(self._orig_image)
|
||||
self.queue_draw()
|
||||
+9
-1
@@ -31,7 +31,15 @@ def dxml(root, depth=0):
|
||||
elif child.tag == "BoxH":
|
||||
additional["orientation"] = "horizontal"
|
||||
if k == "path":
|
||||
v = get_relative_path("../" + v)
|
||||
# Handle path resolution more robustly
|
||||
import os
|
||||
|
||||
# Get the directory where the XML file is located
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Go up to project root and then to the specified path
|
||||
project_root = os.path.dirname(current_dir) # Go up from utils/
|
||||
full_path = os.path.join(project_root, v)
|
||||
v = full_path
|
||||
k = "svg_file"
|
||||
if v == "true" or v == "false":
|
||||
v = v == "true"
|
||||
|
||||
+2
-5
@@ -1,12 +1,9 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# from hyprpy import Hyprland
|
||||
from fabric.audio import Audio
|
||||
from fabric.notifications import Notifications
|
||||
from services.modus import ModusService
|
||||
from loguru import logger
|
||||
|
||||
from fabric.audio import Audio
|
||||
from services.modus import ModusService
|
||||
|
||||
global modus_service
|
||||
try:
|
||||
|
||||
@@ -2,9 +2,10 @@ import math
|
||||
from typing import cast
|
||||
|
||||
import cairo
|
||||
from fabric.widgets.image import Image
|
||||
from gi.repository import Gtk
|
||||
|
||||
from fabric.widgets.image import Image
|
||||
|
||||
|
||||
class CustomImage(Image):
|
||||
def do_render_rectangle(
|
||||
@@ -0,0 +1,255 @@
|
||||
from gi.repository import GLib, Gtk
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
# TODO: UsE BETTER APPROACH IF POSSIBLE
|
||||
|
||||
|
||||
class AnimationManager:
|
||||
_instance = None
|
||||
_animating_widgets = set()
|
||||
_timer_id = None
|
||||
_containers_to_redraw = set()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def add_widget(self, widget):
|
||||
self._animating_widgets.add(widget)
|
||||
if self._timer_id is None:
|
||||
self._timer_id = GLib.timeout_add(22, self._animate_all) # 45 FPS
|
||||
|
||||
def remove_widget(self, widget):
|
||||
self._animating_widgets.discard(widget)
|
||||
if not self._animating_widgets and self._timer_id:
|
||||
# Stop timer when no widgets are animating
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = None
|
||||
# Clear any pending redraws
|
||||
self._containers_to_redraw.clear()
|
||||
|
||||
def _animate_all(self):
|
||||
# Clear previous frame's redraw queue
|
||||
self._containers_to_redraw.clear()
|
||||
|
||||
widgets_to_remove = []
|
||||
for widget in list(self._animating_widgets):
|
||||
if not widget._calculate_position():
|
||||
widgets_to_remove.append(widget)
|
||||
else:
|
||||
container = widget._get_container_for_redraw()
|
||||
if container:
|
||||
self._containers_to_redraw.add(container)
|
||||
|
||||
# Apply all position changes at once
|
||||
for widget in self._animating_widgets:
|
||||
widget._apply_position()
|
||||
|
||||
# Single redraw call per container
|
||||
for container in self._containers_to_redraw:
|
||||
container.queue_draw()
|
||||
|
||||
# Remove completed animations
|
||||
for widget in widgets_to_remove:
|
||||
self.remove_widget(widget)
|
||||
|
||||
return len(self._animating_widgets) > 0 # Continue if widgets remain
|
||||
|
||||
def _get_optimal_interval(self):
|
||||
"""Calculate optimal frame interval based on widget count"""
|
||||
widget_count = len(self._animating_widgets)
|
||||
if widget_count <= 1:
|
||||
return 22 # 45 FPS for single widget
|
||||
elif widget_count <= 3:
|
||||
return 33 # 30 FPS for 2-3 widgets
|
||||
elif widget_count <= 6:
|
||||
return 50 # 20 FPS for 4-6 widgets
|
||||
else:
|
||||
return 67 # 15 FPS for 7+ widgets
|
||||
|
||||
def _start_timer(self):
|
||||
interval = self._get_optimal_interval()
|
||||
self._timer_id = GLib.timeout_add(interval, self._animate_all)
|
||||
|
||||
def _adjust_frame_rate(self):
|
||||
if not self._timer_id:
|
||||
return
|
||||
|
||||
new_interval = self._get_optimal_interval()
|
||||
GLib.source_remove(self._timer_id)
|
||||
self._timer_id = GLib.timeout_add(new_interval, self._animate_all)
|
||||
|
||||
|
||||
class SlideRevealer(Gtk.Overlay):
|
||||
def __init__(self, child: Gtk.Widget, direction="right", duration=300, size=None):
|
||||
super().__init__()
|
||||
|
||||
self.child = child
|
||||
self.direction = direction
|
||||
self.duration = duration
|
||||
self.fixed_size = size
|
||||
self._revealed = False
|
||||
self._animating = False
|
||||
self._start_time = None
|
||||
self._show_animation = False
|
||||
self._pending_position = None
|
||||
|
||||
self._fixed = Gtk.Fixed()
|
||||
self._fixed.set_has_window(False)
|
||||
self._fixed.add(child)
|
||||
self.add_overlay(self._fixed)
|
||||
|
||||
if self.fixed_size:
|
||||
self.set_size_request(self.fixed_size[0], self.fixed_size[1])
|
||||
child.hide()
|
||||
self.show_all()
|
||||
else:
|
||||
child.connect("size-allocate", self._on_size_allocate)
|
||||
child.hide()
|
||||
self.show_all()
|
||||
|
||||
def _on_size_allocate(self, _widget, allocation):
|
||||
if not self.fixed_size:
|
||||
current_req = self.get_size_request()
|
||||
if (
|
||||
current_req[0] != allocation.width
|
||||
or current_req[1] != allocation.height
|
||||
):
|
||||
self.set_size_request(allocation.width, allocation.height)
|
||||
|
||||
def set_reveal_child(self, reveal: bool):
|
||||
if reveal:
|
||||
self.reveal()
|
||||
else:
|
||||
self.hide()
|
||||
|
||||
def reveal(self):
|
||||
if self._revealed or self._animating:
|
||||
return
|
||||
self._revealed = True
|
||||
self._start_animation(show=True)
|
||||
|
||||
def hide(self):
|
||||
if not self._revealed or self._animating:
|
||||
return
|
||||
self._revealed = False
|
||||
self._start_animation(show=False)
|
||||
|
||||
def _start_animation(self, show: bool):
|
||||
if self._animating:
|
||||
AnimationManager.get_instance().remove_widget(self)
|
||||
|
||||
self._cached_dimensions = self._get_dimensions()
|
||||
if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0:
|
||||
self._animating = False
|
||||
return
|
||||
|
||||
self._start_time = GLib.get_monotonic_time()
|
||||
self._animating = True
|
||||
self._show_animation = show
|
||||
|
||||
if show:
|
||||
self.child.show()
|
||||
self._fixed.move(self.child, *self._get_offscreen_pos_cached())
|
||||
|
||||
AnimationManager.get_instance().add_widget(self)
|
||||
|
||||
def _calculate_position(self):
|
||||
if not self._animating:
|
||||
return False
|
||||
|
||||
elapsed = (GLib.get_monotonic_time() - self._start_time) / 1000
|
||||
t = min(elapsed / self.duration, 1.0)
|
||||
|
||||
if self._show_animation:
|
||||
eased = 1 - (1 - t) ** 3 # Inline ease_out_cubic
|
||||
else:
|
||||
eased = t**3 # Ease in cubic for hide animation
|
||||
|
||||
self._pending_position = self._get_position_at_progress_cached(eased)
|
||||
|
||||
if t >= 1.0:
|
||||
self._animating = False
|
||||
self._cached_dimensions = None
|
||||
if not self._show_animation:
|
||||
GLib.idle_add(lambda: self.child.hide())
|
||||
return False
|
||||
return True
|
||||
|
||||
def _apply_position(self):
|
||||
if self._pending_position:
|
||||
x, y = self._pending_position
|
||||
self._fixed.move(self.child, x, y)
|
||||
self._pending_position = None
|
||||
|
||||
def _get_container_for_redraw(self):
|
||||
return self
|
||||
|
||||
def _get_dimensions(self):
|
||||
if self.fixed_size:
|
||||
return self.fixed_size
|
||||
else:
|
||||
alloc = self.child.get_allocation()
|
||||
return alloc.width, alloc.height
|
||||
|
||||
def _get_offscreen_pos_cached(self):
|
||||
w, h = self._cached_dimensions
|
||||
if self.direction == "left":
|
||||
return -w, 0
|
||||
elif self.direction == "right":
|
||||
return w, 0
|
||||
elif self.direction == "top":
|
||||
return 0, -h
|
||||
elif self.direction == "bottom":
|
||||
return 0, h
|
||||
return 0, 0
|
||||
|
||||
def _get_position_at_progress_cached(self, progress):
|
||||
w, h = self._cached_dimensions
|
||||
if self._show_animation:
|
||||
# Showing animation: slide from offscreen to onscreen (0,0)
|
||||
if self.direction == "left":
|
||||
return int(-w + w * progress), 0
|
||||
elif self.direction == "right":
|
||||
return int(w - w * progress), 0
|
||||
elif self.direction == "top":
|
||||
return 0, int(-h + h * progress)
|
||||
elif self.direction == "bottom":
|
||||
return 0, int(h - h * progress)
|
||||
else:
|
||||
# Hiding animation: slide from onscreen (0,0) to offscreen
|
||||
if self.direction == "left":
|
||||
return int(-w * progress), 0 # Slide left (negative x)
|
||||
elif self.direction == "right":
|
||||
return int(w * progress), 0 # Slide right (positive x)
|
||||
elif self.direction == "top":
|
||||
return 0, int(-h * progress) # Slide up (negative y)
|
||||
elif self.direction == "bottom":
|
||||
return 0, int(h * progress) # Slide down (positive y)
|
||||
return 0, 0
|
||||
|
||||
def set_slide_direction(self, direction):
|
||||
self.direction = direction
|
||||
|
||||
def is_revealed(self):
|
||||
return self._revealed
|
||||
|
||||
def is_animating(self):
|
||||
return self._animating
|
||||
|
||||
def get_child_revealed(self):
|
||||
return self._revealed
|
||||
|
||||
def stop_animation(self):
|
||||
if self._animating:
|
||||
AnimationManager.get_instance().remove_widget(self)
|
||||
self._animating = False
|
||||
self._cached_dimensions = None
|
||||
|
||||
def destroy(self):
|
||||
self.stop_animation()
|
||||
super().destroy()
|
||||
@@ -1,7 +1,6 @@
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
|
||||
from utils.roam import modus_service
|
||||
from widgets.popup_window import PopupWindow
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from typing import Any
|
||||
import cairo
|
||||
|
||||
from gi.repository import GtkLayerShell, GLib # type: ignore
|
||||
import cairo
|
||||
from gi.repository import GLib, GtkLayerShell # type: ignore
|
||||
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
from fabric.widgets.widget import Widget
|
||||
from utils.roam import modus_service
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
class MouseCapture(Window):
|
||||
|
||||
+3
-2
@@ -5,11 +5,12 @@ from typing import Literal, cast
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
from gi.repository import Gdk, GObject, Gtk
|
||||
from loguru import logger
|
||||
|
||||
from fabric.core.service import Property
|
||||
from fabric.utils.helpers import extract_css_values, get_enum_member
|
||||
from fabric.widgets.window import Window
|
||||
from gi.repository import Gdk, GObject, Gtk
|
||||
from loguru import logger
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import gi
|
||||
from gi.repository import Gdk, GLib
|
||||
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.entry import Entry
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from widgets.wayland import WaylandWindow as Window
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
|
||||
|
||||
class WiFiPasswordDialog(Window):
|
||||
def __init__(
|
||||
self,
|
||||
ssid: str,
|
||||
on_connect_callback=None,
|
||||
on_cancel_callback=None,
|
||||
on_dialog_closed=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
title="modus",
|
||||
layer="overlay",
|
||||
anchor="center",
|
||||
keyboard_mode="on-demand",
|
||||
visible=False,
|
||||
name="wifi-password-dialog",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.ssid = ssid
|
||||
self.on_connect_callback = on_connect_callback
|
||||
self.on_cancel_callback = on_cancel_callback
|
||||
self.on_dialog_closed = on_dialog_closed
|
||||
self.is_connecting = False
|
||||
self.connection_timeout_id = None
|
||||
|
||||
self._create_dialog_content()
|
||||
self.connect("key-press-event", self._on_key_press)
|
||||
self.connect("notify::visible", self._on_visibility_changed)
|
||||
|
||||
def _create_dialog_content(self):
|
||||
self.wifi_icon = Image(
|
||||
icon_name="network-wireless-symbolic", size=20, name="wifi-dialog-icon"
|
||||
)
|
||||
|
||||
self.title_label = Label(
|
||||
label=f'The Wi-Fi network "{self.ssid}" requires a WPA2 password.',
|
||||
name="wifi-dialog-title",
|
||||
h_align="start",
|
||||
wrap=True,
|
||||
max_width_chars=40,
|
||||
)
|
||||
|
||||
self.title_container = Box(
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
children=[self.wifi_icon, self.title_label],
|
||||
name="wifi-dialog-title-container",
|
||||
h_align="center",
|
||||
)
|
||||
|
||||
self.error_label = Label(
|
||||
label="Incorrect password. Please try again.",
|
||||
name="wifi-dialog-error",
|
||||
h_align="center",
|
||||
visible=False,
|
||||
)
|
||||
|
||||
self.password_label = Label(
|
||||
label="Password:", name="wifi-dialog-password-label", h_align="start"
|
||||
)
|
||||
|
||||
self.password_entry = Entry(
|
||||
placeholder_text="Enter password",
|
||||
name="wifi-dialog-password-entry",
|
||||
visibility=False,
|
||||
h_expand=True,
|
||||
)
|
||||
|
||||
self.password_entry.connect("activate", lambda *_: self._on_join_clicked())
|
||||
self.password_entry.connect("changed", self._on_password_changed)
|
||||
|
||||
self.password_visible = False
|
||||
self.show_password_button = Button(
|
||||
image=Image(icon_name="view-conceal-symbolic", size=16),
|
||||
name="wifi-dialog-show-password-button",
|
||||
on_clicked=self._on_show_password_clicked,
|
||||
)
|
||||
|
||||
self.show_password_label = Label(
|
||||
label="Show password", name="wifi-dialog-show-password-label"
|
||||
)
|
||||
|
||||
self.show_password_box = Box(
|
||||
orientation="h",
|
||||
spacing=8,
|
||||
children=[self.show_password_button, self.show_password_label],
|
||||
name="wifi-dialog-show-password-box",
|
||||
)
|
||||
|
||||
self.cancel_button = Button(
|
||||
label="Cancel",
|
||||
name="wifi-dialog-cancel-button",
|
||||
on_clicked=self._on_cancel_clicked,
|
||||
)
|
||||
|
||||
self.join_button = Button(
|
||||
label="Join",
|
||||
name="wifi-dialog-join-button",
|
||||
on_clicked=self._on_join_clicked,
|
||||
)
|
||||
|
||||
self.button_box = Box(
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
children=[self.cancel_button, self.join_button],
|
||||
name="wifi-dialog-button-box",
|
||||
h_align="end",
|
||||
)
|
||||
|
||||
self.password_container = Box(
|
||||
orientation="v",
|
||||
spacing=6,
|
||||
children=[self.password_label, self.password_entry, self.show_password_box],
|
||||
name="wifi-dialog-password-container",
|
||||
)
|
||||
|
||||
self.content_box = Box(
|
||||
orientation="v",
|
||||
spacing=12,
|
||||
children=[
|
||||
self.title_container,
|
||||
self.error_label,
|
||||
self.password_container,
|
||||
self.button_box,
|
||||
],
|
||||
name="wifi-dialog-content",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.dialog_background = Box(
|
||||
children=[self.content_box],
|
||||
name="wifi-dialog-background",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
|
||||
self.children = self.dialog_background
|
||||
|
||||
self._update_join_button_state()
|
||||
|
||||
def _on_password_changed(self, entry):
|
||||
self._update_join_button_state()
|
||||
|
||||
def _update_join_button_state(self):
|
||||
password = self.password_entry.get_text().strip()
|
||||
has_password = len(password) > 0
|
||||
|
||||
if has_password:
|
||||
self.join_button.set_opacity(1.0)
|
||||
self.join_button.remove_style_class("disabled")
|
||||
else:
|
||||
self.join_button.set_opacity(0.5)
|
||||
self.join_button.add_style_class("disabled")
|
||||
|
||||
def _on_show_password_clicked(self, *args):
|
||||
self.password_visible = not self.password_visible
|
||||
self.password_entry.set_visibility(self.password_visible)
|
||||
|
||||
icon_name = (
|
||||
"view-reveal-symbolic" if self.password_visible else "view-conceal-symbolic"
|
||||
)
|
||||
self.show_password_button.get_image().set_property("icon-name", icon_name)
|
||||
|
||||
def _on_key_press(self, widget, event):
|
||||
keyval = event.keyval
|
||||
|
||||
if keyval == Gdk.KEY_Return or keyval == Gdk.KEY_KP_Enter:
|
||||
self._on_join_clicked()
|
||||
return True
|
||||
elif keyval == Gdk.KEY_Escape:
|
||||
self._on_cancel_clicked()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _on_visibility_changed(self, widget, *args):
|
||||
"""Handle visibility changes"""
|
||||
if self.get_visible():
|
||||
GLib.timeout_add(100, lambda: self.password_entry.grab_focus())
|
||||
|
||||
def _on_cancel_clicked(self, *args):
|
||||
self.hide()
|
||||
if self.on_cancel_callback:
|
||||
self.on_cancel_callback()
|
||||
if self.on_dialog_closed:
|
||||
self.on_dialog_closed()
|
||||
|
||||
def _on_join_clicked(self, *args):
|
||||
if self.is_connecting:
|
||||
return
|
||||
|
||||
password = self.password_entry.get_text().strip()
|
||||
if not password:
|
||||
self.password_entry.grab_focus()
|
||||
return
|
||||
|
||||
self.is_connecting = True
|
||||
self.join_button.set_sensitive(False)
|
||||
|
||||
self.connection_timeout_id = GLib.timeout_add(5000, self._connection_timeout)
|
||||
self.error_label.set_visible(False)
|
||||
|
||||
self.hide()
|
||||
if self.on_connect_callback:
|
||||
self.on_connect_callback(self.ssid, password)
|
||||
if self.on_dialog_closed:
|
||||
self.on_dialog_closed()
|
||||
|
||||
def _connection_timeout(self):
|
||||
if self.is_connecting:
|
||||
self.is_connecting = False
|
||||
self.join_button.set_sensitive(True)
|
||||
self.show_error("Connection timeout. Please try again.")
|
||||
return False
|
||||
|
||||
def show_dialog(self):
|
||||
if self.connection_timeout_id:
|
||||
GLib.source_remove(self.connection_timeout_id)
|
||||
self.connection_timeout_id = None
|
||||
|
||||
self.show_all()
|
||||
self.password_entry.set_text("")
|
||||
self.error_label.set_visible(False)
|
||||
|
||||
self.password_visible = False
|
||||
self.password_entry.set_visibility(False)
|
||||
self.show_password_button.get_image().set_property(
|
||||
"icon-name", "view-conceal-symbolic"
|
||||
)
|
||||
|
||||
self.is_connecting = False
|
||||
self.join_button.set_sensitive(True)
|
||||
|
||||
self._update_join_button_state()
|
||||
|
||||
def show_error(self, message="Incorrect password. Please try again."):
|
||||
if self.connection_timeout_id:
|
||||
GLib.source_remove(self.connection_timeout_id)
|
||||
self.connection_timeout_id = None
|
||||
|
||||
self.is_connecting = False
|
||||
self.join_button.set_sensitive(True)
|
||||
|
||||
if not self.get_visible():
|
||||
self.error_label.set_text(message)
|
||||
self.error_label.set_visible(True)
|
||||
self.show_all()
|
||||
GLib.timeout_add(10, lambda: self._focus_and_select_password())
|
||||
else:
|
||||
self.error_label.set_text(message)
|
||||
self.error_label.set_visible(True)
|
||||
self._focus_and_select_password()
|
||||
|
||||
def _focus_and_select_password(self):
|
||||
try:
|
||||
self.password_entry.grab_focus()
|
||||
self.password_entry.select_region(0, -1)
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
def get_password(self):
|
||||
return self.password_entry.get_text()
|
||||
|
||||
def destroy_dialog(self):
|
||||
self.hide()
|
||||
self.destroy()
|
||||
Reference in new issue
Block a user