Huge updates: ControlCenter: Wi-Fi, Bluetooth, Password Entry Dialog, Notification, CustomRevealer for notif

This commit is contained in:
sankalp committed 2025-08-01 07:54:43 +05:45
1 parent 195e2d0f82
commit 10f1b25264
31 files changed
+1418 -349

No files matched your search

+36
View File
@@ -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
View File
@@ -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):
+15 -27
View File
@@ -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:
+31 -9
View File
@@ -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
+75 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+19 -7
View File
@@ -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)
-1
View File
@@ -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
+3 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+1
View File
@@ -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
View File
@@ -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
-126
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+255
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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")
+282
View File
@@ -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()