diff --git a/config/data.py b/config/data.py index 0680ef1..6d3f59e 100644 --- a/config/data.py +++ b/config/data.py @@ -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, diff --git a/modules/about.py b/modules/about.py index 221659d..de66bb9 100644 --- a/modules/about.py +++ b/modules/about.py @@ -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): diff --git a/modules/controlcenter/bluetooth.py b/modules/controlcenter/bluetooth.py index 9c953ec..d1c7761 100644 --- a/modules/controlcenter/bluetooth.py +++ b/modules/controlcenter/bluetooth.py @@ -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: diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py index bd49bf1..190da28 100644 --- a/modules/controlcenter/main.py +++ b/modules/controlcenter/main.py @@ -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 diff --git a/modules/controlcenter/wifi.py b/modules/controlcenter/wifi.py index 8aa44d7..7c7dd58 100644 --- a/modules/controlcenter/wifi.py +++ b/modules/controlcenter/wifi.py @@ -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 diff --git a/modules/corners.py b/modules/corners.py index 7b7305b..cf3c79e 100644 --- a/modules/corners.py +++ b/modules/corners.py @@ -1,6 +1,5 @@ from fabric.widgets.box import Box from fabric.widgets.shapes import Corner - from widgets.wayland import WaylandWindow as Window diff --git a/modules/dock.py b/modules/dock.py index 142c70d..5636aea 100644 --- a/modules/dock.py +++ b/modules/dock.py @@ -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 diff --git a/modules/notification.py b/modules/notification.py index 9ed2fe9..9bbb755 100644 --- a/modules/notification.py +++ b/modules/notification.py @@ -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) diff --git a/modules/osd.py b/modules/osd.py index 4bc37b3..ce0b94e 100644 --- a/modules/osd.py +++ b/modules/osd.py @@ -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, diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py index 367bd9d..ac7e63b 100644 --- a/modules/panel/components/enhanced_system_tray.py +++ b/modules/panel/components/enhanced_system_tray.py @@ -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 diff --git a/modules/panel/components/indicators.py b/modules/panel/components/indicators.py index 4b9618c..68dab62 100644 --- a/modules/panel/components/indicators.py +++ b/modules/panel/components/indicators.py @@ -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) diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py index 6626158..62fc920 100644 --- a/modules/panel/components/menubar.py +++ b/modules/panel/components/menubar.py @@ -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 diff --git a/modules/panel/main.py b/modules/panel/main.py index 2fac2eb..1994580 100644 --- a/modules/panel/main.py +++ b/modules/panel/main.py @@ -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() diff --git a/modules/switcher.py b/modules/switcher.py index ee7855e..b3b7915 100644 --- a/modules/switcher.py +++ b/modules/switcher.py @@ -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 diff --git a/services/battery.py b/services/battery.py index dc8c460..a2a1cf6 100644 --- a/services/battery.py +++ b/services/battery.py @@ -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", diff --git a/services/brightness.py b/services/brightness.py index 88d0d2e..1031444 100644 --- a/services/brightness.py +++ b/services/brightness.py @@ -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): diff --git a/services/modus.py b/services/modus.py index 31f0c89..9aded1d 100644 --- a/services/modus.py +++ b/services/modus.py @@ -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 diff --git a/services/mpris.py b/services/mpris.py index 6d7aa7f..c5131c2 100644 --- a/services/mpris.py +++ b/services/mpris.py @@ -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) diff --git a/services/network.py b/services/network.py index 0a1a85e..a9d96ec 100644 --- a/services/network.py +++ b/services/network.py @@ -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() \ No newline at end of file + 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}") diff --git a/styles/controlcenter.css b/styles/controlcenter.css index b019a7f..728553f 100644 --- a/styles/controlcenter.css +++ b/styles/controlcenter.css @@ -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); } diff --git a/styles/notification.css b/styles/notification.css index 34f2ccb..e5fa3ac 100644 --- a/styles/notification.css +++ b/styles/notification.css @@ -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 { diff --git a/utils/animator.py b/utils/animator.py index 1799ae6..7dd063d 100644 --- a/utils/animator.py +++ b/utils/animator.py @@ -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 diff --git a/utils/circle_image.py b/utils/circle_image.py deleted file mode 100644 index 5afc189..0000000 --- a/utils/circle_image.py +++ /dev/null @@ -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() diff --git a/utils/exml.py b/utils/exml.py index 80097d0..2c292ab 100644 --- a/utils/exml.py +++ b/utils/exml.py @@ -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" diff --git a/utils/roam.py b/utils/roam.py index 0b05951..a8f7c45 100644 --- a/utils/roam.py +++ b/utils/roam.py @@ -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: diff --git a/utils/custom_image.py b/widgets/custom_image.py similarity index 99% rename from utils/custom_image.py rename to widgets/custom_image.py index 8096a6a..1aae7f6 100644 --- a/utils/custom_image.py +++ b/widgets/custom_image.py @@ -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( diff --git a/widgets/customrevealer.py b/widgets/customrevealer.py new file mode 100644 index 0000000..eb289ba --- /dev/null +++ b/widgets/customrevealer.py @@ -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() diff --git a/widgets/dropdown.py b/widgets/dropdown.py index f41ffc8..cb159c9 100644 --- a/widgets/dropdown.py +++ b/widgets/dropdown.py @@ -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 diff --git a/widgets/mousecapture.py b/widgets/mousecapture.py index 2f0c2a5..af432fc 100644 --- a/widgets/mousecapture.py +++ b/widgets/mousecapture.py @@ -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): diff --git a/widgets/wayland.py b/widgets/wayland.py index 76978da..b7c76aa 100644 --- a/widgets/wayland.py +++ b/widgets/wayland.py @@ -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") diff --git a/widgets/wifi_password_dialog.py b/widgets/wifi_password_dialog.py new file mode 100644 index 0000000..bb0588e --- /dev/null +++ b/widgets/wifi_password_dialog.py @@ -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()