From 9540d2913a7e4734202dc775de9d96115a668f50 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Thu, 31 Jul 2025 20:46:02 +0545 Subject: [PATCH] Huge updates: ControlCenter, DropDown Menu --- main.css | 5 +- main.py | 4 +- modules/about.py | 2 +- modules/controlcenter/bluetooth.py | 363 +++++++ modules/controlcenter/bluetooth.xml | 9 + modules/controlcenter/controlcenter.xml | 27 + modules/controlcenter/main.py | 372 +++++++ modules/controlcenter/player.py | 0 modules/controlcenter/wifi.py | 430 ++++++++ modules/controlcenter/wifi.xml | 9 + modules/corners.py | 2 +- modules/dock.py | 4 +- modules/launcher/main.py | 8 +- modules/notification.py | 85 +- modules/osd.py | 46 +- .../panel/components/enhanced_system_tray.py | 3 + modules/panel/components/indicators.py | 509 +++++----- modules/panel/components/menu_actions.py | 93 -- modules/panel/components/menu_contents.py | 63 -- modules/panel/components/menubar.py | 686 ++++++------- .../panel/components/recording_indicator.py | 6 +- modules/panel/main.py | 62 +- modules/switcher.py | 2 +- services/modus.py | 190 +++- services/network.py | 925 +++++++----------- styles/controlcenter.css | 166 ++++ styles/dropdown.css | 61 ++ styles/menubar.css | 21 - styles/panel.css | 79 +- utils/exml.py | 90 ++ widgets/dropdown.py | 92 ++ widgets/mousecapture.py | 142 +++ widgets/popup_window.py | 145 +++ {utils => widgets}/wayland.py | 0 34 files changed, 3261 insertions(+), 1440 deletions(-) create mode 100644 modules/controlcenter/bluetooth.py create mode 100644 modules/controlcenter/bluetooth.xml create mode 100644 modules/controlcenter/controlcenter.xml create mode 100644 modules/controlcenter/main.py create mode 100644 modules/controlcenter/player.py create mode 100644 modules/controlcenter/wifi.py create mode 100644 modules/controlcenter/wifi.xml delete mode 100644 modules/panel/components/menu_actions.py delete mode 100644 modules/panel/components/menu_contents.py create mode 100644 styles/controlcenter.css create mode 100644 styles/dropdown.css delete mode 100644 styles/menubar.css create mode 100644 utils/exml.py create mode 100644 widgets/dropdown.py create mode 100644 widgets/mousecapture.py create mode 100644 widgets/popup_window.py rename {utils => widgets}/wayland.py (100%) diff --git a/main.css b/main.css index 61e5b2d..29c603a 100644 --- a/main.css +++ b/main.css @@ -1,12 +1,13 @@ @import url("./styles/colors.css"); @import url("./styles/panel.css"); -@import url("./styles/menubar.css"); @import url("./styles/dock.css"); @import url("./styles/switcher.css"); @import url("./styles/launcher.css"); @import url("./styles/osd.css"); -@import url("./styles/about.css"); +@import url("./styles/controlcenter.css"); @import url("./styles/notification.css"); +@import url("./styles/dropdown.css"); +@import url("./styles/about.css"); * { all: unset; diff --git a/main.py b/main.py index 815b680..3ab34a5 100644 --- a/main.py +++ b/main.py @@ -36,7 +36,7 @@ if __name__ == "__main__": panel = Panel() # corners = Corners() dock = Dock() - envnoti = ModusNoti() + modusnoti = ModusNoti() switcher = ApplicationSwitcher() launcher = Launcher() panel.launcher = launcher @@ -51,7 +51,7 @@ if __name__ == "__main__": _ = css_file.connect("changed", lambda *_: set_css()) # Make sure corners is added to the app - app = Application(f"{APP_NAME}", panel, dock, switcher, launcher, envnoti, osd) + app = Application(f"{APP_NAME}", panel, dock, switcher, launcher, modusnoti, osd) def set_css(): app.set_stylesheet_from_file( diff --git a/modules/about.py b/modules/about.py index ad78bda..257a025 100644 --- a/modules/about.py +++ b/modules/about.py @@ -5,7 +5,7 @@ import gi # type: ignore from fabric.widgets.label import Label from fabric.widgets.button import Button from fabric.widgets.box import Box -from utils.wayland import WaylandWindow as Window +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 diff --git a/modules/controlcenter/bluetooth.py b/modules/controlcenter/bluetooth.py new file mode 100644 index 0000000..9c953ec --- /dev/null +++ b/modules/controlcenter/bluetooth.py @@ -0,0 +1,363 @@ +from gi.repository import Gtk, Gdk, GLib + +from fabric.bluetooth import BluetoothClient, BluetoothDevice +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from fabric.widgets.eventbox import EventBox + + +class BluetoothDeviceSlot(CenterBox): + def __init__(self, device: BluetoothDevice, **kwargs): + super().__init__(**kwargs) + self.device = device + self.device.connect("changed", self.on_changed) + self.device.connect( + "notify::closed", lambda *_: self.device.closed and self.destroy() + ) + + self.styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + + self.dimage = Image( + icon_name=device.icon_name + "-symbolic", # type: ignore + size=5, + name="device-icon", + style_classes=" ".join(self.styles), + ) + + self.start_children = [ + Button( + image=self.dimage, + on_clicked=lambda *_: self.toggle_connecting(), + ), + Label(label=device.name), # type: ignore + ] + + self.device.emit("changed") # to update display status + + def toggle_connecting(self): + self.device.emit("changed") + self.device.set_connecting(not self.device.connected) + + def on_changed(self, *_): + self.styles = [ + "connected" if self.device.connected else "", + "paired" if self.device.paired else "", + ] + self.dimage.set_property("style-classes", " ".join(self.styles)) + return + + +class BluetoothConnections(Box): + def __init__(self, parent, show_hidden_devices: bool = False, **kwargs): + super().__init__( + spacing=4, + orientation="vertical", + style="margin: 8px", + **kwargs, + ) + + self.parent = parent + self.show_hidden_devices = show_hidden_devices + + self.client = BluetoothClient(on_device_added=self.on_device_added) + + + + self.title = Box( + orientation="h", + children=[ + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_bluetooth(), + ), + Label("Bluetooth"), + ], + ) + + self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + + self.toggle_button.set_active(self.client.enabled) + self.toggle_button.connect( + "notify::active", + lambda *_: self.client.set_enabled(self.toggle_button.get_active()), + ) + + self.client.connect( + "notify::enabled", + lambda *_: self.toggle_button.set_active(self.client.enabled), + ) + + # Connect scanning state changes to update scan button + self.client.connect( + "notify::scanning", + lambda *_: self.update_scan_label() + ) + + self.not_paired = Box(spacing=2, orientation="vertical") + self.paired = Box(spacing=2, orientation="vertical") + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="bluetooth-refresh-indicator", + label="↓ Pull to scan for devices", + h_align="center", + visible=False + ) + + self.device_box = Box( + spacing=2, + orientation="vertical", + children=[self.refresh_indicator, self.paired, self.not_paired] + ) + + # Create scrolled window with pull-to-refresh + self.scrolled_window = ScrolledWindow( + min_content_size=(303, 400), + max_content_size=(303, 800), + child=self.device_box, + overlay_scroll=True, + ) + + # Add pull-to-refresh functionality + self.setup_pull_to_refresh() + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="bluetooth-widget-top", + ), + Label("Devices", h_align="start", name="devices-title"), + self.scrolled_window, + ] + self.client.notify("scanning") + self.client.notify("enabled") + + # Connect cleanup on destroy + self.connect("destroy", self.on_destroy) + + + + def on_destroy(self, widget): + """Cleanup animations when widget is destroyed""" + self.stop_bounce_animation() + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + # Get the scrolled window's vertical adjustment + self.vadjustment = self.scrolled_window.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Animation state + self.bounce_timeout_id = None + self.bounce_frame = 0 + self.bounce_duration = 60 # Total frames for animation + self.bounce_amplitude = 15 # Maximum bounce height in pixels + + + + # Connect to scroll events + self.scrolled_window.connect("scroll-event", self.on_scroll_event) + self.scrolled_window.connect("button-press-event", self.on_button_press) + self.scrolled_window.connect("button-release-event", self.on_button_release) + self.scrolled_window.connect("motion-notify-event", self.on_motion_notify) + + # Enable events + self.scrolled_window.set_events( + Gdk.EventMask.SCROLL_MASK | + Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK | + Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + # Only handle pull-to-refresh when at the top + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + # Scrolling up at the top - toggle scan (start or stop) + self.client.toggle_scan() + # Trigger smooth bounce animation for scroll + self.start_bounce_animation() + return True # Consume the event + return False # Let normal scrolling continue + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + # Toggle scan (start or stop) + self.client.toggle_scan() + # Trigger elastic animation for pull gesture (most satisfying) + self.start_elastic_animation() + else: + # Stop any ongoing animation + self.stop_bounce_animation() + # Hide refresh indicator + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + # Show refresh indicator when pulling down + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + if self.client.scanning: + self.refresh_indicator.set_label("↑ Release to stop scanning") + else: + self.refresh_indicator.set_label("↑ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + if self.client.scanning: + self.refresh_indicator.set_label("↓ Pull to stop scanning") + else: + self.refresh_indicator.set_label("↓ Pull to scan for devices") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def ease_out_bounce(self, t): + """Smooth bounce easing function (0 to 1)""" + import math + if t < 1/2.75: + return 7.5625 * t * t + elif t < 2/2.75: + t -= 1.5/2.75 + return 7.5625 * t * t + 0.75 + elif t < 2.5/2.75: + t -= 2.25/2.75 + return 7.5625 * t * t + 0.9375 + else: + t -= 2.625/2.75 + return 7.5625 * t * t + 0.984375 + + def ease_out_elastic(self, t): + """Elastic easing function for smoother bounce""" + import math + if t == 0 or t == 1: + return t + + p = 0.3 + s = p / 4 + return math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / p) + 1 + + def start_bounce_animation(self): + """Start smooth bounce animation for the refresh indicator""" + if self.bounce_timeout_id: + GLib.source_remove(self.bounce_timeout_id) + + self.bounce_frame = 0 + # Higher frame rate for smoother animation (16ms = ~60fps) + self.bounce_timeout_id = GLib.timeout_add(16, self.animate_bounce) + + def animate_bounce(self): + """Animate smooth bounce effect with easing""" + if self.bounce_frame >= self.bounce_duration: + # Animation finished + self.refresh_indicator.set_margin_top(0) + self.bounce_timeout_id = None + return False + + # Calculate progress (0 to 1) + progress = self.bounce_frame / self.bounce_duration + + # Apply easing function for smooth bounce + eased_progress = self.ease_out_bounce(progress) + + # Calculate bounce offset (starts high, bounces down to 0) + bounce_offset = int(self.bounce_amplitude * (1 - eased_progress)) + self.refresh_indicator.set_margin_top(max(0, bounce_offset)) + + self.bounce_frame += 1 + return True + + def start_elastic_animation(self): + """Start elastic animation for more dramatic effect""" + if self.bounce_timeout_id: + GLib.source_remove(self.bounce_timeout_id) + + self.bounce_frame = 0 + self.bounce_duration = 80 # Longer for elastic effect + self.bounce_amplitude = 20 # Higher amplitude + self.bounce_timeout_id = GLib.timeout_add(16, self.animate_elastic) + + def animate_elastic(self): + """Animate elastic bounce effect""" + if self.bounce_frame >= self.bounce_duration: + # Animation finished + self.refresh_indicator.set_margin_top(0) + self.bounce_timeout_id = None + return False + + # Calculate progress (0 to 1) + progress = self.bounce_frame / self.bounce_duration + + # Apply elastic easing + eased_progress = self.ease_out_elastic(progress) + + # Calculate bounce offset + bounce_offset = int(self.bounce_amplitude * (1 - eased_progress)) + self.refresh_indicator.set_margin_top(max(0, bounce_offset)) + + self.bounce_frame += 1 + return True + + + + def stop_bounce_animation(self): + """Stop any ongoing animations""" + if self.bounce_timeout_id: + GLib.source_remove(self.bounce_timeout_id) + self.bounce_timeout_id = None + self.refresh_indicator.set_margin_top(0) + + def update_scan_label(self): + """Update scanning state appearance""" + if self.client.scanning: + # Show scanning feedback in refresh indicator + self.refresh_indicator.set_label("Scanning for devices...") + self.refresh_indicator.set_visible(True) + self.refresh_indicator.add_style_class("scanning") + else: + # Hide scanning feedback + if not self.is_pulling: + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("scanning") + + def on_device_added(self, client: BluetoothClient, address: str): + if not (device := client.get_device(address)): + return + + if (device.name in ["", None]) and not self.show_hidden_devices: + return + + slot = BluetoothDeviceSlot(device, paired=device.paired) + + if device.paired: + return self.paired.add(slot) + return self.not_paired.add(slot) diff --git a/modules/controlcenter/bluetooth.xml b/modules/controlcenter/bluetooth.xml new file mode 100644 index 0000000..04e1989 --- /dev/null +++ b/modules/controlcenter/bluetooth.xml @@ -0,0 +1,9 @@ + + + + + self.bluetooth_man + + + + diff --git a/modules/controlcenter/controlcenter.xml b/modules/controlcenter/controlcenter.xml new file mode 100644 index 0000000..5a82fe4 --- /dev/null +++ b/modules/controlcenter/controlcenter.xml @@ -0,0 +1,27 @@ + + + + + self.wlan_widget + self.bluetooth_widget + + + self.focus_widget + + + + + + + + + self.music_widget + + + diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py new file mode 100644 index 0000000..127479f --- /dev/null +++ b/modules/controlcenter/main.py @@ -0,0 +1,372 @@ +from fabric.utils import idle_add +from fabric.utils.helpers import ( + get_relative_path, +) +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.label import Label +from fabric.widgets.scale import Scale +from fabric.widgets.svg import Svg +from gi.repository import Gdk, GLib + +from modules.controlcenter.bluetooth import BluetoothConnections +from modules.controlcenter.wifi import WifiConnections +from services.brightness import Brightness +from utils.exml import exml +from utils.roam import audio_service, modus_service +from widgets.wayland import WaylandWindow as Window + +brightness_service = Brightness.get_initial() + +# FIX: Icon not showing up in control center +# TODO: Add Player + + +class ModusControlCenter(Window): + def __init__(self, **kwargs): + super().__init__( + layer="top", + title="modus", + anchor="top right", + margin="2px 10px 0px 0px", + exclusivity="auto", + keyboard_mode="on-demand", + name="control-center-menu", + visible=False, + **kwargs, + ) + self.focus_mode = False + self._updating_brightness = False + self._updating_volume = False + + self.add_keybinding("Escape", self.hide_controlcenter) + + volume = 100 + wlan = modus_service.sc("wlan-changed", self.wlan_changed) + bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed) + music = modus_service.sc("music-changed", self.audio_changed) + + audio_service.connect("changed", self.audio_changed) + audio_service.connect("changed", self.volume_changed) + + self.wlan_label = Label(wlan, name="wifi-widget-label", h_align="start") + self.bluetooth_label = Label( + bluetooth, name="bluetooth-widget-label", h_align="start" + ) + self.volume_scale = Scale( + value=volume, + min_value=0, + max_value=100, + increments=(5, 5), + name="volume-widget-slider", + size=30, + h_expand=True, + ) + self.volume_scale.connect("change-value", self.set_volume) + self.volume_scale.connect("scroll-event", self.on_volume_scroll) + + current_brightness = brightness_service.screen_brightness + brightness_percentage = ( + int((current_brightness / brightness_service.max_screen) * 100) + if brightness_service.max_screen > 0 + else 50 + ) + + self.brightness_scale = Scale( + value=brightness_percentage, + min_value=0, + max_value=100, + increments=(5, 5), + name="brightness-widget-slider", + size=30, + h_expand=True, + ) + + # Only connect brightness controls if brightness service is available + if brightness_service.max_screen > 0: + self.brightness_scale.connect("change-value", self.set_brightness) + self.brightness_scale.connect("scroll-event", self.on_brightness_scroll) + brightness_service.connect("screen", self.brightness_changed) + else: + # Disable brightness scale if no backlight device available + self.brightness_scale.set_sensitive(False) + + self.music_widget = Box( + name="music-widget", + # children=[Player()], + ) + + self.wifi_man = WifiConnections(self) + self.bluetooth_man = BluetoothConnections(self) + + self.has_bluetooth_open = False + self.has_wifi_open = False + + self.bluetooth_svg = Svg( + svg_file=get_relative_path( + "../../config/assets/icons/bluetooth.svg" + if bluetooth == "On" + else "../../config/assets/icons/bluetooth-off.svg" + ), + style_classes="icon", + ) + self.wifi_svg = ( + Svg( + svg_file=get_relative_path("../../config/assets/icons/wifi.svg"), + style_classes="icon", + ) + if wlan != "No Connection" + else Svg( + svg_file=get_relative_path("../../config/assets/icons/wifi-off.svg"), + style_classes="icon", + ) + ) + + self.bluetooth_widget = Button( + name="bluetooth-widget", + child=Box( + orientation="h", + children=[ + self.bluetooth_svg, + Box( + name="bluetooth-widget-info", + orientation="vertical", + children=[ + Label( + label="Bluetooth", + style_classes="title ct", + h_align="start", + ), + self.bluetooth_label, + ], + ), + ], + ), + on_clicked=self.open_bluetooth, + ) + + self.wlan_widget = Button( + name="wifi-widget", + child=Box( + orientation="h", + children=[ + self.wifi_svg, + Box( + name="wifi-widget-info", + orientation="vertical", + children=[ + Label( + label="Wi-Fi", style_classes="title ct", h_align="start" + ), + self.wlan_label, + ], + ), + ], + ), + on_clicked=self.open_wifi, + ) + + self.focus_icon = Svg( + svg_file=get_relative_path("../../config/assets/icons/dnd-off.svg"), + style_classes="icon", + ) + + self.focus_widget = Button( + name="focus-widget", + child=Box( + orientation="h", + children=[ + self.focus_icon, + Label(label="Focus", style_classes="title ct", h_align="start"), + ], + ), + on_clicked=self.set_dont_disturb, + ) + + self.widgets = exml( + file=get_relative_path("controlcenter.xml"), + root=Box, + tags={ + "Box": Box, + "Button": Button, + "Label": Label, + "Scale": Scale, + "Svg": Svg, + }, + refs={ + "self.wlan_label": self.wlan_label, + "self.bluetooth_label": self.bluetooth_label, + "self.volume_scale": self.volume_scale, + "self.brightness_scale": self.brightness_scale, + "self.music_widget": self.music_widget, + "self.bluetooth_widget": self.bluetooth_widget, + "self.wlan_widget": self.wlan_widget, + "self.focus_widget": self.focus_widget, + }, + ) + + self.bluetooth_widgets = exml( + file=get_relative_path("bluetooth.xml"), + root=Box, + tags={ + "Box": Box, + "Button": Button, + "Label": Label, + "Scale": Scale, + "Svg": Svg, + }, + refs={"self.bluetooth_man": self.bluetooth_man}, + ) + + self.wifi_widgets = exml( + file=get_relative_path("wifi.xml"), + root=Box, + tags={ + "Box": Box, + "Button": Button, + "Label": Label, + "Scale": Scale, + "Svg": Svg, + }, + refs={"self.wifi_man": self.wifi_man}, + ) + + self.center_box = CenterBox(start_children=[self.widgets]) + + self.bluetooth_center_box = CenterBox(start_children=[self.bluetooth_widgets]) + + self.wifi_center_box = CenterBox(start_children=[self.wifi_widgets]) + + self.widgets.set_size_request(300, -1) + self.bluetooth_center_box.set_size_request(300, -1) + self.wifi_center_box.set_size_request(300, -1) + + self.children = self.center_box + + def set_dont_disturb(self, *_): + self.focus_mode = not self.focus_mode + modus_service.dont_disturb = self.focus_mode + self.focus_icon.set_from_file( + get_relative_path( + "../../config/assets/icons/dnd.svg" + if self.focus_mode + else "../../config/assets/icons/dnd-off.svg" + ) + ) + + def set_volume(self, _, __, volume): + self._updating_volume = True + audio_service.speaker.volume = round(volume) + self._updating_volume = False + + def set_brightness(self, _, __, brightness): + self._updating_brightness = True + brightness_value = int((brightness / 100) * brightness_service.max_screen) + brightness_service.screen_brightness = brightness_value + self._updating_brightness = False + + def brightness_changed(self, _, brightness_value): + if self._updating_brightness: + return + + if brightness_service.max_screen > 0: + brightness_percentage = int( + (brightness_value / brightness_service.max_screen) * 100 + ) + + GLib.idle_add( + lambda: self.brightness_scale.set_value(brightness_percentage) + ) + + def on_volume_scroll(self, widget, event): + current_value = self.volume_scale.get_value() + scroll_step = 5 + if event.direction == Gdk.ScrollDirection.UP: + new_value = min(100, current_value + scroll_step) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_value = max(0, current_value - scroll_step) + else: + return False + + self.volume_scale.set_value(new_value) + return True + + def on_brightness_scroll(self, widget, event): + current_value = self.brightness_scale.get_value() + scroll_step = 5 + if event.direction == Gdk.ScrollDirection.UP: + new_value = min(100, current_value + scroll_step) + elif event.direction == Gdk.ScrollDirection.DOWN: + new_value = max(0, current_value - scroll_step) + else: + return False + + self.brightness_scale.set_value(new_value) + return True + + def set_children(self, children): + self.children = children + + def open_bluetooth(self, *_): + idle_add(lambda *_: self.set_children(self.bluetooth_center_box)) + self.has_bluetooth_open = True + + def open_wifi(self, *_): + idle_add(lambda *_: self.set_children(self.wifi_center_box)) + self.has_wifi_open = True + + def close_bluetooth(self, *_): + idle_add(lambda *_: self.set_children(self.center_box)) + self.has_bluetooth_open = False + + def close_wifi(self, *_): + idle_add(lambda *_: self.set_children(self.center_box)) + self.has_wifi_open = False + + def _set_mousecapture(self, visible: bool): + self.set_visible(visible) + if not visible: + self.close_bluetooth() + self.close_wifi() + + def volume_changed( + self, + _, + ): + if self._updating_volume: + return + + GLib.idle_add( + lambda: self.volume_scale.set_value(int(audio_service.speaker.volume)) + ) + + def wlan_changed(self, _, wlan): + self.wifi_svg.set_from_file( + get_relative_path( + "../../config/assets/icons/wifi.svg" + if wlan != "No Connection" + else "../../config/assets/icons/wifi-off.svg" + ) + ) + GLib.idle_add(lambda: self.wlan_label.set_property("label", wlan)) + + def bluetooth_changed(self, _, bluetooth): + self.bluetooth_svg.set_from_file( + get_relative_path( + "../../config/assets/icons/bluetooth.svg" + if bluetooth == "On" + else "../../config/assets/icons/bluetooth-off.svg" + ) + ) + GLib.idle_add(lambda: self.bluetooth_label.set_property("label", bluetooth)) + + def audio_changed(self, *_): + pass + + def _init_mousecapture(self, mousecapture): + self._mousecapture_parent = mousecapture + + def hide_controlcenter(self, *_): + self._mousecapture_parent.toggle_mousecapture() + self.set_visible(False) diff --git a/modules/controlcenter/player.py b/modules/controlcenter/player.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/controlcenter/wifi.py b/modules/controlcenter/wifi.py new file mode 100644 index 0000000..cf5e890 --- /dev/null +++ b/modules/controlcenter/wifi.py @@ -0,0 +1,430 @@ +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow +from gi.repository import Gdk, GLib, Gtk + +from services.network import NetworkService + + +# FIX: Make sure the network only scan when the pull to refresh started +# TODO: Show visual Feeadback and animation like bluetooth + + +class WifiNetworkSlot(CenterBox): + def __init__(self, network_data: dict, wifi_service, **kwargs): + super().__init__(**kwargs) + self.network_data = network_data + self.wifi_service = wifi_service + + # Get network info + self.ssid = network_data.get("ssid", "Unknown") + self.bssid = network_data.get("bssid", "") + self.strength = network_data.get("strength", 0) + self.icon_name = network_data.get( + "icon-name", "network-wireless-signal-none-symbolic" + ) + + # The network service puts the same active-ap reference in all networks + # We need to check if THIS specific network's BSSID matches the active AP's BSSID + self.is_connected = False + if self.wifi_service and self.wifi_service._ap: + active_bssid = self.wifi_service._ap.get_bssid() + self.is_connected = active_bssid == self.bssid + + # Initialize styles based on connection state + self.styles = [ + "connected" if self.is_connected else "", + ] + + # Create connection status indicator using symbolic WiFi icon + self.dimage = Image( + icon_name="network-wireless-symbolic", + size=5, + name="device-icon", + style_classes=" ".join(self.styles), + ) + + self.start_children = [ + Button( + image=self.dimage, + on_clicked=lambda *_: self.toggle_connecting(), + ), + Label(label=self.ssid), + ] + + # Emit initial change to update display + self.on_changed() + + def toggle_connecting(self): + """Toggle WiFi network connection""" + # Check if this network is currently connected by comparing BSSIDs + is_currently_connected = False + if self.wifi_service and self.wifi_service._ap: + active_bssid = self.wifi_service._ap.get_bssid() + is_currently_connected = active_bssid == self.bssid + + if is_currently_connected: + # Disconnect from network + success = self.wifi_service.disconnect_network(self.ssid) + if success: + self.is_connected = False + else: + # Connect to network (try without password first, could be enhanced for password input) + success = self.wifi_service.connect_network(self.ssid) + if success: + self.is_connected = True + + # Update display after connection attempt + self.on_changed() + + def on_changed(self, *_): + """Update display when connection state changes""" + # Check if this network is currently connected by comparing BSSIDs + is_currently_connected = False + if self.wifi_service and self.wifi_service._ap: + active_bssid = self.wifi_service._ap.get_bssid() + is_currently_connected = active_bssid == self.bssid + + self.is_connected = is_currently_connected + self.styles = [ + "connected" if self.is_connected else "", + ] + self.dimage.set_property("style-classes", " ".join(self.styles)) + return + + +class WifiConnections(Box): + def __init__(self, parent, **kwargs): + super().__init__( + spacing=4, + orientation="vertical", + style="margin: 8px", + **kwargs, + ) + + self.parent = parent + self.network_service = NetworkService() + self.wifi_service = None + + # Wait for network service to be ready + self.network_service.connect("device-ready", self.on_network_ready) + + # Create pull-to-refresh indicator + self.refresh_indicator = Label( + name="wifi-refresh-indicator", + label="↓ Pull to scan for networks", + h_align="center", + visible=False, + ) + + self.title = Box( + orientation="h", + children=[ + Button( + image=Image(icon_name="back", size=10), + on_clicked=lambda *_: self.parent.close_wifi(), + ), + Label("WiFi"), + ], + ) + + self.toggle_button = Gtk.Switch(visible=True, name="toggle-button") + + self.available_networks = Box(spacing=2, orientation="vertical") + + self.device_box = Box( + spacing=2, + orientation="vertical", + children=[self.refresh_indicator, self.available_networks], + ) + + # Create scrolled window with pull-to-refresh + self.scrolled_window = ScrolledWindow( + min_content_size=(303, 400), + max_content_size=(303, 800), + child=self.device_box, + overlay_scroll=True, + ) + + # Add pull-to-refresh functionality + self.setup_pull_to_refresh() + + self.children = [ + CenterBox( + start_children=self.title, + end_children=self.toggle_button, + name="wifi-widget-top", + ), + Label("Networks", h_align="start", name="networks-title"), + self.scrolled_window, + ] + + # Connect cleanup on destroy + self.connect("destroy", self.on_destroy) + + def on_network_ready(self, *_): + """Called when network service is ready""" + self.wifi_service = self.network_service.wifi_device + if self.wifi_service: + # Set up WiFi toggle + self.toggle_button.set_active(self.wifi_service.enabled) + self.toggle_button.connect( + "notify::active", + lambda *_: setattr( + self.wifi_service, "enabled", self.toggle_button.get_active() + ), + ) + + # Connect to WiFi service signals + self.wifi_service.connect("notify::enabled", self.on_wifi_enabled_changed) + self.wifi_service.connect("scanning", self.on_scanning_changed) + self.wifi_service.connect("changed", self.update_networks) + # Also connect to access point changes to update connection states + self.wifi_service.connect( + "notify::access-points", self.refresh_network_states + ) + + # Initial network update + self.update_networks() + + def on_wifi_enabled_changed(self, *_): + """Handle WiFi enabled state changes""" + if self.wifi_service: + self.toggle_button.set_active(self.wifi_service.enabled) + + def on_scanning_changed(self, service, is_scanning): + """Handle scanning state changes""" + print(f"WiFi scanning state changed: {is_scanning}") # Debug output + self.update_scan_indicator(is_scanning) + + def update_networks(self, *_): + """Update the list of available networks""" + if not self.wifi_service: + return + + # Clear existing networks + for child in self.available_networks.get_children(): + child.destroy() + + # Add current networks + access_points = self.wifi_service.access_points + for ap_data in access_points: + if ap_data.get("ssid") and ap_data.get("ssid") != "Unknown": + network_slot = WifiNetworkSlot(ap_data, self.wifi_service) + self.available_networks.add(network_slot) + + # Update all network connection states + self.refresh_network_states() + + def refresh_network_states(self, *_): + """Refresh connection states for all network slots""" + for child in self.available_networks.get_children(): + if hasattr(child, "on_changed"): + child.on_changed() + + def setup_pull_to_refresh(self): + """Setup pull-to-refresh gesture for the scrolled window""" + # Get the scrolled window's vertical adjustment + self.vadjustment = self.scrolled_window.get_vadjustment() + + # Track gesture state + self.pull_start_y = 0 + self.is_pulling = False + self.pull_threshold = 50 # pixels to trigger refresh + + # Animation state + self.bounce_timeout_id = None + self.bounce_frame = 0 + self.bounce_duration = 60 # Total frames for animation + self.bounce_amplitude = 15 # Maximum bounce height in pixels + + # Connect to scroll events + self.scrolled_window.connect("scroll-event", self.on_scroll_event) + self.scrolled_window.connect("button-press-event", self.on_button_press) + self.scrolled_window.connect("button-release-event", self.on_button_release) + self.scrolled_window.connect("motion-notify-event", self.on_motion_notify) + + # Enable events + self.scrolled_window.set_events( + Gdk.EventMask.SCROLL_MASK + | Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + ) + + def on_scroll_event(self, widget, event): + """Handle scroll events for pull-to-refresh""" + # Only handle pull-to-refresh when at the top + if self.vadjustment.get_value() <= 0: + if event.direction == Gdk.ScrollDirection.UP: + # Scrolling up at the top - start scan + print("WiFi scroll scan triggered") # Debug output + if self.wifi_service: + self.wifi_service.scan() + # Trigger smooth bounce animation for scroll + self.start_bounce_animation() + return True # Consume the event + return False # Let normal scrolling continue + + def on_button_press(self, widget, event): + """Handle button press for touch/drag gestures""" + if self.vadjustment.get_value() <= 0: + self.pull_start_y = event.y + self.is_pulling = True + return False + + def on_button_release(self, widget, event): + """Handle button release for touch/drag gestures""" + if self.is_pulling: + pull_distance = event.y - self.pull_start_y + if pull_distance > self.pull_threshold: + # Trigger scan + print("WiFi pull-to-refresh scan triggered") # Debug output + if self.wifi_service: + self.wifi_service.scan() + # Trigger elastic animation for pull gesture (most satisfying) + self.start_elastic_animation() + else: + # Stop any ongoing animation + self.stop_bounce_animation() + # Hide refresh indicator + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("ready-to-refresh") + self.is_pulling = False + return False + + def on_motion_notify(self, widget, event): + """Handle motion events for visual feedback during pull""" + if self.is_pulling and self.vadjustment.get_value() <= 0: + pull_distance = event.y - self.pull_start_y + if pull_distance > 0: + # Show refresh indicator when pulling down + self.refresh_indicator.set_visible(True) + if pull_distance >= self.pull_threshold: + self.refresh_indicator.set_label("↑ Release to scan") + self.refresh_indicator.add_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_label("↓ Pull to scan for networks") + self.refresh_indicator.remove_style_class("ready-to-refresh") + else: + self.refresh_indicator.set_visible(False) + return False + + def update_scan_indicator(self, is_scanning): + """Update scanning state appearance""" + print(f"WiFi update_scan_indicator called: {is_scanning}") # Debug output + if is_scanning: + # Show scanning feedback in refresh indicator + self.refresh_indicator.set_label("Scanning for networks...") + self.refresh_indicator.set_visible(True) + self.refresh_indicator.add_style_class("scanning") + print("WiFi scanning indicator shown") # Debug output + else: + # Hide scanning feedback + if not self.is_pulling: + self.refresh_indicator.set_visible(False) + self.refresh_indicator.remove_style_class("scanning") + print("WiFi scanning indicator hidden") # Debug output + + def ease_out_bounce(self, t): + """Smooth bounce easing function (0 to 1)""" + if t < 1 / 2.75: + return 7.5625 * t * t + elif t < 2 / 2.75: + t -= 1.5 / 2.75 + return 7.5625 * t * t + 0.75 + elif t < 2.5 / 2.75: + t -= 2.25 / 2.75 + return 7.5625 * t * t + 0.9375 + else: + t -= 2.625 / 2.75 + return 7.5625 * t * t + 0.984375 + + def ease_out_elastic(self, t): + """Elastic easing function for smoother bounce""" + import math + + if t == 0 or t == 1: + return t + + p = 0.3 + s = p / 4 + return math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / p) + 1 + + def start_bounce_animation(self): + """Start smooth bounce animation for the refresh indicator""" + print("WiFi bounce animation started") # Debug output + if self.bounce_timeout_id: + GLib.source_remove(self.bounce_timeout_id) + + self.bounce_frame = 0 + # Higher frame rate for smoother animation (16ms = ~60fps) + self.bounce_timeout_id = GLib.timeout_add(16, self.animate_bounce) + + def animate_bounce(self): + """Animate smooth bounce effect with easing""" + if self.bounce_frame >= self.bounce_duration: + # Animation finished + self.refresh_indicator.set_margin_top(0) + self.bounce_timeout_id = None + return False + + # Calculate progress (0 to 1) + progress = self.bounce_frame / self.bounce_duration + + # Apply easing function for smooth bounce + eased_progress = self.ease_out_bounce(progress) + + # Calculate bounce offset (starts high, bounces down to 0) + bounce_offset = int(self.bounce_amplitude * (1 - eased_progress)) + self.refresh_indicator.set_margin_top(max(0, bounce_offset)) + + self.bounce_frame += 1 + return True + + def start_elastic_animation(self): + """Start elastic animation for more dramatic effect""" + print("WiFi elastic animation started") # Debug output + if self.bounce_timeout_id: + GLib.source_remove(self.bounce_timeout_id) + + self.bounce_frame = 0 + self.bounce_duration = 80 # Longer for elastic effect + self.bounce_amplitude = 20 # Higher amplitude + self.bounce_timeout_id = GLib.timeout_add(16, self.animate_elastic) + + def animate_elastic(self): + """Animate elastic bounce effect""" + if self.bounce_frame >= self.bounce_duration: + # Animation finished + self.refresh_indicator.set_margin_top(0) + self.bounce_timeout_id = None + return False + + # Calculate progress (0 to 1) + progress = self.bounce_frame / self.bounce_duration + + # Apply elastic easing + eased_progress = self.ease_out_elastic(progress) + + # Calculate bounce offset + bounce_offset = int(self.bounce_amplitude * (1 - eased_progress)) + self.refresh_indicator.set_margin_top(max(0, bounce_offset)) + + self.bounce_frame += 1 + return True + + def stop_bounce_animation(self): + """Stop any ongoing animations""" + if self.bounce_timeout_id: + GLib.source_remove(self.bounce_timeout_id) + self.bounce_timeout_id = None + self.refresh_indicator.set_margin_top(0) + + def on_destroy(self, widget): + """Cleanup animations when widget is destroyed""" + self.stop_bounce_animation() + diff --git a/modules/controlcenter/wifi.xml b/modules/controlcenter/wifi.xml new file mode 100644 index 0000000..5a98e08 --- /dev/null +++ b/modules/controlcenter/wifi.xml @@ -0,0 +1,9 @@ + + + + + self.wifi_man + + + + diff --git a/modules/corners.py b/modules/corners.py index 9ec8e6e..7b7305b 100644 --- a/modules/corners.py +++ b/modules/corners.py @@ -1,7 +1,7 @@ from fabric.widgets.box import Box from fabric.widgets.shapes import Corner -from utils.wayland import WaylandWindow as Window +from widgets.wayland import WaylandWindow as Window class MyCorner(Box): diff --git a/modules/dock.py b/modules/dock.py index f87cd3e..142c70d 100644 --- a/modules/dock.py +++ b/modules/dock.py @@ -20,10 +20,12 @@ from fabric.widgets.revealer import Revealer from gi.repository import Gdk, GLib, Gtk from utils.icon_resolver import IconResolver from utils.occlusion import check_occlusion -from utils.wayland import WaylandWindow as Window +from widgets.wayland import WaylandWindow as Window from modules.corners import MyCorner +# TODO: use modus_service + def read_config(): """Read and return the full configuration from the JSON file, handling missing file.""" diff --git a/modules/launcher/main.py b/modules/launcher/main.py index 4bf0bb6..7afd05b 100644 --- a/modules/launcher/main.py +++ b/modules/launcher/main.py @@ -1,19 +1,16 @@ from typing import List, Optional, Tuple -import utils.icons as icons from fabric.core.service import Property -from fabric.utils import exec_shell_command_async, get_relative_path from fabric.widgets.box import Box -from fabric.widgets.button import Button from fabric.widgets.entry import Entry -from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow from gi.repository import Gdk, GLib + from modules.launcher.plugin_manager import PluginManager from modules.launcher.result import Result from modules.launcher.result_item import ResultItem from modules.launcher.trigger_config import TriggerConfig -from utils.wayland import WaylandWindow as Window +from widgets.wayland import WaylandWindow as Window # Constants SEARCH_DEBOUNCE_MS = 150 @@ -883,7 +880,6 @@ class Launcher(Window): # Enter - activate selected result if keyval == Gdk.KEY_Return: - # Check for Shift+Enter for alternative actions if event.state & Gdk.ModifierType.SHIFT_MASK: if self.results and 0 <= self.selected_index < len(self.results): diff --git a/modules/notification.py b/modules/notification.py index 5263079..14d1fd1 100644 --- a/modules/notification.py +++ b/modules/notification.py @@ -5,11 +5,13 @@ 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 utils.wayland import WaylandWindow as Window +from widgets.wayland import WaylandWindow as Window +from fabric.widgets.revealer import Revealer from fabric.notifications import ( NotificationAction, Notifications, Notification, + NotificationCloseReason, NotificationImagePixmap, ) import os @@ -27,6 +29,9 @@ NOTIFICATION_IMAGE_SIZE = 48 NOTIFICATION_TIMEOUT = 10 * 1000 +# TODO: make the revealer animation smoother and the on every new notification the revealer work as it work for initial notification (rapid notification issue) + + def get_notification_image_pixbuf( notification: Notification, ) -> GdkPixbuf.Pixbuf | None: @@ -168,36 +173,66 @@ class NotificationWidget(Box): self.destroy() def destroy_noti(self, *_): - parent.remove(self) if (parent := self.get_parent()) else None, # type: ignore + (parent.remove(self) if (parent := self.get_parent()) else None,) # type: ignore self.destroy() -class ModusNoti(Window): - def __init__(self, **kwargs): +class NotificationRevealer(Revealer): + 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__( - margin="8px 8px 8px 8px", - name="notification-window", - title="Notification Center", - layer="overlay", - anchor="top right", child=Box( - size=2, - spacing=4, - orientation="v", - ).build( - lambda viewport, _: Notifications( - on_notification_added=self.on_notification_added - ) + children=[self.notif_box], ), - **kwargs, + transition_duration=300, + transition_type="slide-left", ) - def on_notification_added(self, notifs_service, nid): - modus_service.cache_notification(notifs_service.get_notification_from_id(nid)) - if modus_service.dont_disturb == "True": - return - self.get_child().add( - NotificationWidget( - cast(Notification, notifs_service.get_notification_from_id(nid)) - ) + self.connect( + "notify::child-revealed", + self._on_child_revealed, ) + self.notification.connect("closed", self.on_resolved) + + def _on_child_revealed(self, *args): + if not self.get_child_revealed(): + if self.on_transition_end: + self.on_transition_end() + self.destroy() + + def on_resolved( + self, + notification: Notification, + reason: NotificationCloseReason, + ): + self.set_property("transition-type", "slide-right") + self.set_reveal_child(False) + + +class ModusNoti(Window): + def __init__(self): + self._server = Notifications() + self.notifications = Box( + v_expand=True, + h_expand=True, + style="margin: 1px 0px 1px 1px;", + orientation="v", + spacing=5, + ) + self._server.connect("notification-added", self.on_new_notification) + super().__init__( + anchor="top right", + child=self.notifications, + layer="overlay", + all_visible=True, + visible=True, + exclusive=False, + ) + + def on_new_notification(self, fabric_notif, id): + notification: Notification = fabric_notif.get_notification_from_id(id) + new_box = NotificationRevealer(notification, on_transition_end=None) + self.notifications.add(new_box) + new_box.set_reveal_child(True) diff --git a/modules/osd.py b/modules/osd.py index bf35df2..8bc776b 100644 --- a/modules/osd.py +++ b/modules/osd.py @@ -13,7 +13,7 @@ from gi.repository import GLib, GObject import math from services.brightness import Brightness from utils.animator import Animator -from utils.wayland import WaylandWindow as Window +from widgets.wayland import WaylandWindow as Window class AnimatedScale(Scale): @@ -80,7 +80,9 @@ class BrightnessOSDContainer(Box): normalized_brightness = self._normalize_brightness(value) self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/brightness/brightness-{self.get_svg(normalized_brightness)}.svg" + f"../config/assets/icons/brightness/brightness-{ + self.get_svg(normalized_brightness) + }.svg" ) ) self.scale.animate_value(normalized_brightness) @@ -162,7 +164,9 @@ class AudioOSDContainer(Box): volume = 0 self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/volume/audio-volume-{self.get_svg(volume)}.svg" + f"../config/assets/icons/volume/audio-volume-{ + self.get_svg(volume) + }.svg" ) ) @@ -171,7 +175,9 @@ class AudioOSDContainer(Box): self.scale.remove_style_class("muted") self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/volume/audio-volume-{self.get_svg(volume)}.svg" + f"../config/assets/icons/volume/audio-volume-{ + self.get_svg(volume) + }.svg" ) ) # self.icon.remove_style_class("muted") @@ -206,14 +212,18 @@ class AudioOSDContainer(Box): volume = 0 self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/volume/audio-volume-{self.get_svg(volume)}.svg" + f"../config/assets/icons/volume/audio-volume-{ + self.get_svg(volume) + }.svg" ) ) else: self.scale.remove_style_class("muted") self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/volume/audio-volume-{self.get_svg(volume)}.svg" + f"../config/assets/icons/volume/audio-volume-{ + self.get_svg(volume) + }.svg" ) ) @@ -288,7 +298,9 @@ class MicrophoneOSDContainer(Box): self.audio.microphone.set_volume(volume) self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/mic/microphone-{self.get_svg(volume)}.svg" + f"../config/assets/icons/mic/microphone-{ + self.get_svg(volume) + }.svg" ) ) @@ -298,14 +310,18 @@ class MicrophoneOSDContainer(Box): volume = 0 self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/mic/microphone-{self.get_svg(volume)}.svg" + f"../config/assets/icons/mic/microphone-{ + self.get_svg(volume) + }.svg" ) ) else: self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/mic/microphone-{self.get_svg(volume)}.svg" + f"../config/assets/icons/mic/microphone-{ + self.get_svg(volume) + }.svg" ) ) self.scale.remove_style_class("muted") @@ -330,7 +346,9 @@ class MicrophoneOSDContainer(Box): self.previous_volume = current_volume self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/mic/microphone-{self.get_svg(current_volume)}.svg" + f"../config/assets/icons/mic/microphone-{ + self.get_svg(current_volume) + }.svg" ) ) self.update_volume() @@ -346,13 +364,17 @@ class MicrophoneOSDContainer(Box): volume = 0 self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/mic/microphone-{self.get_svg(volume)}.svg" + f"../config/assets/icons/mic/microphone-{ + self.get_svg(volume) + }.svg" ) ) else: self.osd_window_image.set_from_file( get_relative_path( - f"../config/assets/icons/mic/microphone-{self.get_svg(volume)}.svg" + f"../config/assets/icons/mic/microphone-{ + self.get_svg(volume) + }.svg" ) ) self.scale.remove_style_class("muted") diff --git a/modules/panel/components/enhanced_system_tray.py b/modules/panel/components/enhanced_system_tray.py index d171fa6..367bd9d 100644 --- a/modules/panel/components/enhanced_system_tray.py +++ b/modules/panel/components/enhanced_system_tray.py @@ -10,6 +10,9 @@ from gi.repository import Gtk, GdkPixbuf from fabric.system_tray.widgets import SystemTrayItem +# FIX: the tooltip should show application names instead of unknown + + def patched_do_update_properties(self, *_): # Try default GTK theme first icon_name = self._item.icon_name diff --git a/modules/panel/components/indicators.py b/modules/panel/components/indicators.py index 660fb65..1761bcd 100644 --- a/modules/panel/components/indicators.py +++ b/modules/panel/components/indicators.py @@ -1,137 +1,34 @@ -from gi.repository import GLib - -import utils.icons as icons from fabric.bluetooth import BluetoothClient 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.battery import Battery as BatteryService -from services.network import NetworkClient +from services.network import NetworkService +from services.battery import Battery +from utils.roam import modus_service -class WifiIndicator(Button): +class BluetoothIndicator(Box): def __init__(self, **kwargs): - super().__init__(**kwargs) - - self.network = NetworkClient() - self.wifi_icon = Svg( - name="indicators-icon", - size=24, - svg_file=get_relative_path("../../../config/assets/icons/wifi.svg"), - ) - - self.add(self.wifi_icon) - - self.network.connect("ready", self.on_network_ready) - - if self.network.is_ready: - self.setup_signals() - - def on_network_ready(self, *args): - self.setup_signals() - - def setup_signals(self): - self.network.connect("changed", self.on_network_changed) - self.network.connect("wifi-device-added", self.on_wifi_device_added) - self.network.connect("wifi-device-removed", self.on_wifi_device_removed) - self.network.connect("ethernet-device-added", self.on_ethernet_device_added) - self.network.connect("ethernet-device-removed", self.on_ethernet_device_removed) - - if self.network.wifi_device: - self.network.wifi_device.connect("changed", self.on_wifi_changed) - self.network.wifi_device.connect("ap-added", self.on_wifi_changed) - self.network.wifi_device.connect("ap-removed", self.on_wifi_changed) - - if self.network.ethernet_device: - self.network.ethernet_device.connect("changed", self.on_ethernet_changed) - - self.update_state() - - def update_state(self): - if ( - self.network.ethernet_device - and self.network.ethernet_device.state == "activated" - ): - self.wifi_icon.set_from_file( - get_relative_path("../../../config/assets/icons/wifi.svg") - ) - tooltip = f"Connected to Ethernet ({self.network.ethernet_device.speed})" - self.set_tooltip_text(tooltip) - return - - if not self.network.wifi_device: - self.wifi_icon.set_from_file( - get_relative_path("../../../config/assets/icons/wifi-off.svg") - ) - tooltip = "No WiFi device found" - elif not self.network.wifi_device.wireless_enabled: - self.wifi_icon.set_from_file( - get_relative_path("../../../config/assets/icons/wifi-off.svg") - ) - tooltip = "WiFi disabled" - else: - active_ap = self.network.wifi_device.active_access_point - if active_ap: - strength = active_ap.strength - # Use wifi.svg for all connected states since we don't have signal strength icons - self.wifi_icon.set_from_file( - get_relative_path("../../../config/assets/icons/wifi.svg") - ) - tooltip = f"Connected to {active_ap.ssid} ({strength}%)" - else: - self.wifi_icon.set_from_file( - get_relative_path("../../../config/assets/icons/wifi-off.svg") - ) - tooltip = "Not connected" - - self.set_tooltip_text(tooltip) - - def on_wifi_device_added(self, *args): - self.network.wifi_device.connect("changed", self.on_wifi_changed) - self.network.wifi_device.connect("ap-added", self.on_wifi_changed) - self.network.wifi_device.connect("ap-removed", self.on_wifi_changed) - self.update_state() - - def on_wifi_device_removed(self, *args): - self.update_state() - - def on_network_changed(self, *args): - self.update_state() - - def on_wifi_changed(self, *args): - self.update_state() - - def on_ethernet_device_added(self, *args): - if self.network.ethernet_device: - self.network.ethernet_device.connect("changed", self.on_ethernet_changed) - self.update_state() - - def on_ethernet_device_removed(self, *args): - self.update_state() - - def on_ethernet_changed(self, *args): - self.update_state() - - -class BluetoothIndicator(Button): - def __init__(self, **kwargs): - super().__init__(**kwargs) + super().__init__(name="bluetooth-indicator", orientation="h", **kwargs) self.bluetooth = BluetoothClient() - - # Use SVG icons instead of font icons self.bt_icon = Svg( name="bt-icon", size=20, svg_file=get_relative_path("../../../config/assets/icons/bluetooth.svg"), ) - self.add(self.bt_icon) - self.bluetooth.connect("changed", self.on_bluetooth_changed) + self.bt_button = Button(name="bt-button", child=self.bt_icon) + + self.add(self.bt_button) + + modus_service.connect("bluetooth-changed", self.on_bluetooth_changed) + self.bluetooth.connect("changed", self.on_bluetooth_direct_changed) self.bluetooth.connect("device-added", self.on_device_added) self.bluetooth.connect("device-removed", self.on_device_removed) + self.update_modus_service_bluetooth_state() self.update_state() def update_state(self): @@ -159,161 +56,269 @@ class BluetoothIndicator(Button): ) tooltip = "No devices connected" - self.set_tooltip_text(tooltip) + self.bt_button.set_tooltip_text(tooltip) - def on_bluetooth_changed(self, *args): + def on_bluetooth_changed(self, service, new_bluetooth_state): + self.update_state() + + def on_bluetooth_direct_changed(self, *args): + self.update_modus_service_bluetooth_state() self.update_state() def on_device_added(self, _, address): + self.update_modus_service_bluetooth_state() self.update_state() def on_device_removed(self, _, address): + self.update_modus_service_bluetooth_state() self.update_state() - -class Battery(Box): - def __init__(self, **kwargs): - super().__init__(orientation="v", visible=True, **kwargs) - - self._battery = BatteryService() - self._battery.changed.connect(self.update_battery) - - self.icon = Svg( - name="indicators-icon", - size=24, - svg_file=get_relative_path( - "../../../config/assets/icons/battery/battery-100.svg" - ), - ) - self.battery_button = Button( - child=self.icon, - ) - - self.battery_box = Box( - name="battery-box", - orientation="h", - children=[self.battery_button], - ) - - self.battery_button.set_has_tooltip(True) - self.battery_box.set_has_tooltip(True) - - self.battery_button.connect("query-tooltip", self.on_query_tooltip) - self.battery_box.connect("query-tooltip", self.on_query_tooltip) - - self.add(self.battery_box) - - GLib.idle_add(self.update_battery) - - def on_query_tooltip(self, *_): - tooltip = _[-1] - tooltip.set_markup(self.get_tooltip_text()) - return True - - def get_tooltip_text(self): - if not self._battery.is_present: - return "No battery detected" - - percentage = self._battery.percentage - state = self._battery.state - - capacity = int(float(self._battery.capacity.rstrip("%"))) - tooltip_points = [] - - if state == "FULLY_CHARGED": - status = f"{icons.battery_full} Fully Charged" - elif state == "CHARGING": - status = f"{icons.bat_charging} Charging" - elif percentage <= 15 and state == "DISCHARGING": - status = f"{icons.bat_alert} Low Battery" - elif state == "DISCHARGING": - status = f"{icons.battery_0} Discharging" + def update_modus_service_bluetooth_state(self): + if not self.bluetooth.enabled: + bluetooth_state = "disabled" else: - status = "Battery" - tooltip_points.append(f"• Status: {status}") - - tooltip_points.append(f"• Level: {int(percentage)}%") - tooltip_points.append(f"• Battery Health: {capacity}%") - - power_profile = self._battery.power_profile - if power_profile: - if power_profile == "power-saver": - profile_icon = icons.power_saving - elif power_profile == "performance": - profile_icon = icons.power_performance + connected_devices = self.bluetooth.connected_devices + if connected_devices: + if len(connected_devices) == 1: + device = connected_devices[0] + bluetooth_state = f"connected:{device.alias}" + if ( + hasattr(device, "battery_percentage") + and device.battery_percentage > 0 + ): + bluetooth_state += f":{device.battery_percentage:.0f}%" + else: + bluetooth_state = f"connected:{len(connected_devices)}_devices" else: - profile_icon = icons.power_balanced - tooltip_points.append(f"• Profile: {profile_icon} {power_profile}") + bluetooth_state = "enabled" - temp = self._battery.temperature - if temp != "N/A": - tooltip_points.append(f"• Temperature: {temp}") + modus_service.bluetooth = bluetooth_state + + +class NetworkIndicator(Box): + def __init__(self, **kwargs): + super().__init__(name="network-indicator", orientation="h", **kwargs) + + self.network_service = NetworkService() + + self.network_icon = Svg( + name="network-icon", + size=25, + svg_file=get_relative_path("../../../config/assets/icons/wifi.svg"), + ) + + self.network_button = Button(name="network-button", child=self.network_icon) + + self.add(self.network_button) + modus_service.connect("wlan-changed", self.on_wlan_changed) + self.network_service.connect("device-ready", self.on_device_ready) + + self.update_modus_service_wlan_state() + self.update_state() + + def on_wlan_changed(self, service, new_wlan_state): + self.update_state() + + def on_device_ready(self, *args): + if self.network_service.wifi_device: + self.network_service.wifi_device.connect( + "changed", self.on_network_direct_changed + ) + + if self.network_service.ethernet_device: + self.network_service.ethernet_device.connect( + "changed", self.on_network_direct_changed + ) + + self.update_modus_service_wlan_state() + self.update_state() + + def on_network_direct_changed(self, *args): + self.update_modus_service_wlan_state() + self.update_state() + + def on_network_changed(self, *args): + self.update_modus_service_wlan_state() + self.update_state() + + def update_modus_service_wlan_state(self): + primary_device = self.network_service.primary_device + wlan_state = "disconnected" + + if primary_device == "wifi" and self.network_service.wifi_device: + wifi = self.network_service.wifi_device + if not wifi.enabled: + wlan_state = "disabled" + elif wifi.internet == "activated": + wlan_state = f"connected:{wifi.ssid}" + if wifi.strength >= 0: + wlan_state += f":{wifi.strength}%" + elif wifi.internet == "activating": + wlan_state = f"connecting:{wifi.ssid}" + else: + wlan_state = "enabled" + + elif primary_device == "wired" and self.network_service.ethernet_device: + ethernet = self.network_service.ethernet_device + if ethernet.internet == "activated": + wlan_state = "ethernet:connected" + if hasattr(ethernet, "speed") and ethernet.speed > 0: + wlan_state += f":{ethernet.speed}Mbps" + elif ethernet.internet == "activating": + wlan_state = "ethernet:connecting" + else: + wlan_state = "ethernet:disconnected" + + modus_service.wlan = wlan_state + + def update_state(self): + primary_device = self.network_service.primary_device + tooltip = "No network connection" + icon_file = "wifi-off.svg" + + if primary_device == "wifi" and self.network_service.wifi_device: + wifi = self.network_service.wifi_device + if not wifi.enabled: + icon_file = "wifi-off.svg" + tooltip = "WiFi disabled" + elif wifi.internet == "activated": + icon_file = "wifi.svg" + tooltip = f"Connected to {wifi.ssid}" + if wifi.strength >= 0: + tooltip += f" ({wifi.strength}%)" + elif wifi.internet == "activating": + icon_file = "wifi.svg" + tooltip = f"Connecting to {wifi.ssid}..." + else: + icon_file = "wifi-off.svg" + tooltip = "WiFi disconnected" + + elif primary_device == "wired" and self.network_service.ethernet_device: + ethernet = self.network_service.ethernet_device + if ethernet.internet == "activated": + icon_file = "ethernet.svg" + tooltip = "Ethernet connected" + if hasattr(ethernet, "speed") and ethernet.speed > 0: + tooltip += f" ({ethernet.speed} Mbps)" + elif ethernet.internet == "activating": + icon_file = "wifi.svg" + tooltip = "Ethernet connecting..." + else: + icon_file = "wifi-off.svg" + tooltip = "Ethernet disconnected" + + self.network_icon.set_from_file( + get_relative_path(f"../../../config/assets/icons/{icon_file}") + ) + self.network_button.set_tooltip_text(tooltip) + + +class BatteryIndicator(Box): + def __init__(self, **kwargs): + super().__init__(name="battery-indicator", orientation="h", **kwargs) + + self.battery_service = Battery() + + self.battery_icon = Svg( + name="battery-icon", + size=25, + svg_file=get_relative_path("../../../config/assets/icons/battery.svg"), + ) + + self.battery_button = Button(name="battery-button", child=self.battery_icon) + + self.add(self.battery_button) + modus_service.connect("battery-changed", self.on_battery_changed) + self.battery_service.connect("changed", self.on_battery_direct_changed) + + self.update_modus_service_battery_state() + self.update_state() + + def on_battery_changed(self, service, new_battery_state): + self.update_state() + + def on_battery_direct_changed(self, *args): + self.update_modus_service_battery_state() + self.update_state() + + def update_modus_service_battery_state(self): + if not self.battery_service.is_present: + battery_state = "not_present" + else: + percentage = self.battery_service.percentage + state = self.battery_service.state.lower() + + battery_state = f"{state}:{percentage}%" + + if state == "discharging": + time_to_empty = self.battery_service.time_to_empty + if time_to_empty != "N/A": + battery_state += f":{time_to_empty}" + elif state == "charging": + time_to_full = self.battery_service.time_to_full + if time_to_full != "N/A": + battery_state += f":{time_to_full}" + + modus_service.battery = battery_state + + def get_battery_icon_level(self, percentage): + if percentage >= 90: + return "100" + elif percentage >= 80: + return "090" + elif percentage >= 70: + return "080" + elif percentage >= 60: + return "070" + elif percentage >= 50: + return "060" + elif percentage >= 40: + return "050" + elif percentage >= 30: + return "040" + elif percentage >= 20: + return "030" + elif percentage >= 10: + return "020" + else: + return "010" + + def get_battery_icon_file(self, percentage, is_charging): + level = self.get_battery_icon_level(percentage) + suffix = "-charging" if is_charging else "" + return f"battery/battery-{level}{suffix}.svg" + + def get_battery_tooltip(self, percentage, state): + tooltip = f"Battery: {percentage}%" if state == "CHARGING": - time_to_full = self._battery.time_to_full - if time_to_full: - tooltip_points.append(f"• Time until full: {time_to_full}") + tooltip += " (Charging)" + time_to_full = self.battery_service.time_to_full + if time_to_full != "N/A": + tooltip += f" - {time_to_full} until full" elif state == "DISCHARGING": - time_to_empty = self._battery.time_to_empty - if time_to_empty: - tooltip_points.append(f"• Time remaining: {time_to_empty}") + time_to_empty = self.battery_service.time_to_empty + if time_to_empty != "N/A": + tooltip += f" - {time_to_empty} remaining" + elif state == "FULLY_CHARGED": + tooltip += " (Fully charged)" - return "\n".join(tooltip_points) + return tooltip - def update_battery(self, *_): - if not self._battery.is_present: - self.set_visible(False) - return True - - percentage = self._battery.percentage - state = self._battery.state - charging = state in ["CHARGING", "FULLY_CHARGED"] - - if percentage <= 15 and not charging: - self.icon.add_style_class("alert") + def update_state(self): + if not self.battery_service.is_present: + icon_file = "battery.svg" + tooltip = "No battery detected" else: - self.icon.remove_style_class("alert") + percentage = self.battery_service.percentage + state = self.battery_service.state + is_charging = state in ["CHARGING", "FULLY_CHARGED"] - if state == "FULLY_CHARGED" or (percentage >= 100 and state == "CHARGING"): - self.icon.set_from_file( - get_relative_path( - "../../../config/assets/icons/battery/battery-100-charging.svg" - ) - ) - elif state == "CHARGING": - # Calculate battery level and use corresponding charging icon - level = min(100, max(0, int(percentage // 10) * 10)) - if level == 0 and percentage > 0: - level = 10 - icon_name = f"battery-{level:03d}-charging.svg" - self.icon.set_from_file( - get_relative_path(f"../../../config/assets/icons/battery/{icon_name}") - ) - else: - # Calculate battery level and use corresponding icon - level = min(100, max(0, int(percentage // 10) * 10)) - if level == 0 and percentage > 0: - level = 10 - icon_name = f"battery-{level:03d}.svg" - self.icon.set_from_file( - get_relative_path(f"../../../config/assets/icons/battery/{icon_name}") - ) + icon_file = self.get_battery_icon_file(percentage, is_charging) + tooltip = self.get_battery_tooltip(percentage, state) - self.set_visible(True) - return True - - -class Indicators(Box): - def __init__(self, **kwargs): - super().__init__( - name="indicators", - orientation="h", - spacing=10, - children=[ - Battery(), - WifiIndicator(), - BluetoothIndicator(), - ], - **kwargs, + # Update icon and tooltip + self.battery_icon.set_from_file( + get_relative_path(f"../../../config/assets/icons/{icon_file}") ) - self.show_all() + self.battery_button.set_tooltip_text(tooltip) diff --git a/modules/panel/components/menu_actions.py b/modules/panel/components/menu_actions.py deleted file mode 100644 index be7a51a..0000000 --- a/modules/panel/components/menu_actions.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Menu action handlers for the macOS-style menu bar. -This file contains all the implementations for menu item actions. -""" - -from fabric.hyprland.service import Hyprland -from fabric.utils import exec_shell_command - - -class MenuActionHandler: - - def __init__(self): - pass - - def execute_action(self, action): - try: - # Window menu actions - if action == "Move Window to Left": - Hyprland.send_command("dispatch movewindow l") - elif action == "Move Window to Right": - Hyprland.send_command("dispatch movewindow r") - elif action == "Cycle Through Windows": - Hyprland.send_command("dispatch cyclenext") - elif action == "Float": - Hyprland.send_command("dispatch togglefloating") - elif action == "Pseudo": - Hyprland.send_command("dispatch pseudo") - elif action == "Center": - Hyprland.send_command("dispatch centerwindow") - elif action == "Group": - Hyprland.send_command("dispatch togglegroup") - elif action == "Pin": - Hyprland.send_command("dispatch pin") - elif action == "Quit": - Hyprland.send_command("dispatch killactive") - - # Go menu actions - elif action == "Back": - Hyprland.send_command("dispatch workspace e-1") - elif action == "Forward": - Hyprland.send_command("dispatch workspace e+1") - - # Edit menu actions - - # View menu actions - elif action == "Full Screen": - Hyprland.send_command("dispatch fullscreen") - elif action == "Zoom In": - exec_shell_command("wtype -k ctrl+plus") - elif action == "Zoom Out": - exec_shell_command("wtype -k ctrl+minus") - elif action == "Actual Size": - exec_shell_command("wtype -k ctrl+0") - - # Help menu actions - elif action == "Hyprland Help": - exec_shell_command("xdg-open https://wiki.hyprland.org/") - elif action == "Arch Wiki": - exec_shell_command("xdg-open https://wiki.archlinux.org/") - elif action == "Report a Bug...": - exec_shell_command("xdg-open https://github.com/S4NKALP/Modus/issues") - - # System menu actions - elif action == "About This PC": - exec_shell_command("gnome-system-monitor") - elif action == "Force Quit": - exec_shell_command("gnome-system-monitor") - elif action == "Shutdown": - exec_shell_command("systemctl poweroff") - elif action == "Restart": - exec_shell_command("systemctl reboot") - elif action == "Sleep": - exec_shell_command("systemctl suspend") - elif action == "Lock": - exec_shell_command("hyprlock") - - # App menu actions (for hiding apps) - elif action.startswith("Hide ") and not action == "Hide Others": - action.replace("Hide ", "") - Hyprland.send_command("dispatch movetoworkspacesilent special:hidden") - elif action == "Hide Others": - Hyprland.send_command( - "dispatch movetoworkspacesilent special:hidden,^(activewindow)" - ) - elif action == "Show All": - Hyprland.send_command("dispatch movetoworkspace e+0,special:hidden") - elif action.startswith("Quit "): - action.replace("Quit ", "") - Hyprland.send_command("dispatch killactive") - else: - print(f"No implementation for action: {action}") - except Exception as e: - print(f"Error executing command for '{action}': {e}") diff --git a/modules/panel/components/menu_contents.py b/modules/panel/components/menu_contents.py deleted file mode 100644 index 9e09089..0000000 --- a/modules/panel/components/menu_contents.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Menu contents definitions for the macOS-style menu bar. -This file contains all the menu items and their organization. -""" - - -def get_default_menu_contents(): - return { - "Hyprland": [], - "File": [], - "Edit": [], - "View": ["Full Screen", "---", "Zoom In", "Zoom Out", "Actual Size"], - "Go": [ - "Back", - "Forward", - ], - "Window": [ - "Move Window to Left", - "Move Window to Right", - "Cycle Through Windows", - "---", - "Float", - "Pseudo", - "Center", - "Group", - "Pin", - "Quit", - ], - "Help": [ - "---", - "Hyprland Help", - "Arch Wiki", - "Keyboard Shortcuts", - "---", - "Report a Bug...", - ], - } - - -def get_app_menu_template(): - return ["About {app_name}", "---", "Hide {app_name}", "---", "Quit {app_name}"] - - -def create_app_menu(app_name): - template = get_app_menu_template() - return [ - item.format(app_name=app_name) if "{app_name}" in item else item - for item in template - ] - - -def get_menu_contents_for_app(app_name): - contents = get_default_menu_contents().copy() - - if app_name and app_name != "Hyprland": - # Replace the Hyprland menu with app-specific menu - contents["Hyprland"] = create_app_menu(app_name) - - return contents - - -def get_app_specific_menu_contents(app_name): - return get_menu_contents_for_app(app_name) diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py index c0ba1fa..a4c39dc 100644 --- a/modules/panel/components/menubar.py +++ b/modules/panel/components/menubar.py @@ -1,364 +1,372 @@ +import os +import subprocess + +from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow +from fabric.utils import FormattedString from fabric.widgets.box import Box from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox from fabric.widgets.label import Label -from fabric.hyprland.widgets import HyprlandActiveWindow -from utils.wayland import WaylandWindow -from gi.repository import Gtk, GLib -from .menu_contents import get_default_menu_contents, get_app_specific_menu_contents -from .menu_actions import MenuActionHandler + +from modules.about import About +from utils.roam import modus_service +from widgets.dropdown import ModusDropdown, dropdown_divider +from widgets.mousecapture import DropDownMouseCapture -class DropdownWindow(WaylandWindow): - def __init__(self, x=0, y=0, parent_menubar=None, **kwargs): +class AppName: + def __init__(self, path="/usr/share/applications"): + self.files = os.listdir(path) if os.path.exists(path) else [] + self.path = path + + def get_app_name(self, wmclass): + desktop_file = "" + for f in self.files: + if f.startswith(wmclass + ".desktop"): + desktop_file = f + + desktop_app_name = wmclass + + if desktop_file == "": + return wmclass + try: + with open(os.path.join(self.path, desktop_file), "r") as f: + lines = f.readlines() + for line in lines: + if line.startswith("Name="): + desktop_app_name = line.split("=")[1].strip() + break + except: + return wmclass + return desktop_app_name + + def format_app_name(self, title, wmclass, update=False): + name = wmclass + if name == "": + name = title + + # Try to get the proper app name from desktop file + name = self.get_app_name(wmclass=wmclass) + + # Smart title formatting (capitalize first letter) + name = str(name).title() + if "." in name: + name = name.split(".")[-1] + + if update: + modus_service.current_active_app_name = name + return name + + +app_name_class = AppName() + + +def format_window(title, wmclass): + name = app_name_class.format_app_name(title, wmclass, True) + if not name or name == "": + return "Desktop" + return name + + +def dropdown_option( + label: str = "", + keybind: str = "", + on_click='echo "ModusPanelDropdown Action"', + on_clicked=None, +): + def on_click_subthread(button): + # Execute the action first + if on_clicked: + on_clicked(button) + else: + subprocess.Popen( + f"nohup {on_click} &", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # Hide dropdown by finding the current visible dropdown and calling its hide method + from widgets.dropdown import dropdowns + + for dropdown in dropdowns: + if dropdown.is_visible() and hasattr(dropdown, "hide_via_mousecapture"): + dropdown.hide_via_mousecapture() + break + + return Button( + child=CenterBox( + start_children=[ + Label(label=label, h_align="start", name="dropdown-option-label"), + ], + end_children=[ + Label(label=keybind, h_align="end", name="dropdown-option-keybind") + ], + orientation="horizontal", + h_align="fill", + h_expand=True, + v_expand=True, + ), + name="dropdown-option", + h_align="fill", + on_clicked=on_click_subthread, + h_expand=True, + v_expand=True, + ) + + +class SystemDropdown(ModusDropdown): + def __init__(self, parent, **kwargs): super().__init__( - name="dropdown-window", - title="Menu Dropdown", - layer="overlay", - anchor="top left", - exclusivity="none", - margin=f"{y}px 0px 0px {x}px", - visible=False, - all_visible=True, + dropdown_id="os-menu", + parent=parent, + dropdown_children=[ + dropdown_option( + "About this PC", on_clicked=lambda _: About().toggle(_) + ), + dropdown_divider("---------------------"), + dropdown_option( + "System Settings...", + # TODO: Open Modus own setting + # on_click="xdg-open settings", + ), + dropdown_divider("---------------------"), + dropdown_option("Force Quit", "", "hyprctl kill"), + dropdown_divider("---------------------"), + dropdown_option("Sleep", "", "systemctl suspend"), + dropdown_option("Restart...", "", "systemctl reboot"), + dropdown_option("Shut Down...", "", "shutdown now"), + dropdown_divider("---------------------"), + dropdown_option("Lock Screen", "󰘳 L", "hyprlock"), + ], **kwargs, ) - # Store reference to parent menubar for closing dropdown - self.parent_menubar = parent_menubar - self.content_box = Box( - name="dropdown-content", - orientation="v", - spacing=2, +class MenuBarDropdowns: + def __init__(self, parent): + self.parent = parent + + # System dropdown + self.system_dropdown = SystemDropdown(parent=parent) + self.menu_button_dropdown = DropDownMouseCapture( + layer="bottom", child_window=self.system_dropdown ) - self.add(self.content_box) + self.menu_button = Button( + label="Modus", + name="menu-button", + style_classes="button", + on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(), + ) + self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button) - self.connect("enter-notify-event", self.on_mouse_enter) - self.connect("leave-notify-event", self.on_mouse_leave) + # Global menu dropdowns + self.global_title_menu_about = dropdown_option( + f"About {modus_service.current_active_app_name}" + ) + self.global_menu_title = DropDownMouseCapture( + layer="bottom", + child_window=ModusDropdown( + dropdown_id="global-menu-title", + parent=parent, + dropdown_children=[self.global_title_menu_about], + ), + ) - self.auto_close_timer = None + self.global_menu_file = None + self.global_menu_edit = None + self.global_menu_view = DropDownMouseCapture( + layer="bottom", + child_window=ModusDropdown( + dropdown_id="global-menu-view", + parent=parent, + dropdown_children=[ + dropdown_option( + "Enter Full Screen", + on_click="hyprctl dispatch fullscreen", + ), + ], + ), + ) + self.global_menu_go = None + self.global_menu_window = DropDownMouseCapture( + layer="bottom", + child_window=ModusDropdown( + dropdown_id="global-menu-window", + parent=parent, + dropdown_children=[ + dropdown_option( + "Zoom", + on_clicked=lambda _: subprocess.run( + "bash ~/.config/scripts/zoomer.sh", shell=True + ), + ), + dropdown_option( + "Move Window to Left", + on_click="hyprctl dispatch movewindow l", + ), + dropdown_option( + "Move Window to Right", + on_click="hyprctl dispatch movewindow r", + ), + dropdown_option( + "Cycle Through Windows", + on_click="hyprctl dispatch cyclenext", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Float", on_click="hyprctl dispatch togglefloating" + ), + dropdown_option("Quit", on_click="hyprctl dispatch killactive"), + dropdown_option("Pseudo", on_click="hyprctl dispatch pseudo"), + dropdown_option( + "Toggle Split", on_click="hyprctl dispatch togglesplit" + ), + dropdown_option("Center", on_click="hyprctl dispatch centerwindow"), + dropdown_option("Group", on_click="hyprctl dispatch togglegroup"), + dropdown_option( + "Pin", + on_clicked=lambda _: subprocess.run( + "bash ~/.config/scripts/winpin.sh", shell=True + ), + ), + ], + ), + ) - def on_mouse_enter(self, widget, event): - # Only handle if the event is for the main dropdown window, not child widgets - if event.detail != 2: # 2 = Gdk.NotifyType.INFERIOR (child widget) - # Cancel auto-close timer when mouse enters - if self.auto_close_timer: - GLib.source_remove(self.auto_close_timer) - self.auto_close_timer = None - return False + self.global_menu_help = DropDownMouseCapture( + layer="bottom", + child_window=ModusDropdown( + dropdown_id="global-menu-help", + parent=parent, + dropdown_children=[ + dropdown_option( + "Modus", + on_click="xdg-open https://github.com/S4NKALP/Modus/issues", + ), + dropdown_divider("---------------------"), + dropdown_option( + "Hyprland Wiki", on_click="xdg-open https://wiki.hyprland.org/" + ), + ], + ), + ) - def on_mouse_leave(self, widget, event): - # Only handle if actually leaving the dropdown window, not entering child widgets - if event.detail != 2: # 2 = Gdk.NotifyType.INFERIOR (child widget) - # Start auto-close timer when mouse leaves - if not self.auto_close_timer: - self.auto_close_timer = GLib.timeout_add( - 300, self.auto_close_callback - ) # 300ms delay - return False + # Create menu buttons + modus_service.connect( + "current-active-app-name-changed", + lambda _, value: self.global_title_menu_about.set_property( + "label", f"About {value}" + ), + ) + self.global_menu_button_title = Button( + child=ActiveWindow( + formatter=FormattedString( + "{ format_window('None', 'Hyprland') if win_title == '' and win_class == '' else format_window(win_title, win_class) }", + format_window=format_window, + ) + ), + name="global-title-button", + style_classes="button", + on_clicked=lambda _: self.global_menu_title.toggle_mousecapture(), + ) + self.global_menu_title.child_window.set_pointing_to( + self.global_menu_button_title + ) - def auto_close_callback(self): - if self.parent_menubar: - self.parent_menubar.hide_dropdown() - self.auto_close_timer = None - return False + self.global_menu_button_file = Button( + label="File", name="global-menu-button-file", style_classes="button" + ) + self.global_menu_button_edit = Button( + label="Edit", name="global-menu-button-edit", style_classes="button" + ) + self.global_menu_button_view = Button( + label="View", + name="global-menu-button-view", + style_classes="button", + on_clicked=lambda _: self.global_menu_view.toggle_mousecapture(), + ) + self.global_menu_view.child_window.set_pointing_to(self.global_menu_button_view) + self.global_menu_button_go = Button( + label="Go", name="global-menu-button-go", style_classes="button" + ) + self.global_menu_button_window = Button( + label="Window", + name="global-menu-button-window", + style_classes="button", + on_clicked=lambda _: self.global_menu_window.toggle_mousecapture(), + ) + self.global_menu_window.child_window.set_pointing_to( + self.global_menu_button_window + ) + self.global_menu_button_help = Button( + label="Help", + name="global-menu-button-help", + style_classes="button", + on_clicked=lambda _: self.global_menu_help.toggle_mousecapture(), + ) + self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help) + + modus_service.connect("current-dropdown-changed", self.changed_dropdown) + modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns) + + def hide_dropdowns(self, *_): + self.menu_button.remove_style_class("active") + self.global_menu_button_edit.remove_style_class("active") + self.global_menu_button_file.remove_style_class("active") + self.global_menu_button_go.remove_style_class("active") + self.global_menu_button_help.remove_style_class("active") + self.global_menu_button_title.remove_style_class("active") + self.global_menu_button_view.remove_style_class("active") + self.global_menu_button_window.remove_style_class("active") + + def changed_dropdown(self, _, dropdown_id): + self.hide_dropdowns(_, True) + match dropdown_id: + case "os-menu": + self.menu_button.add_style_class("active") + case "global-menu-edit": + self.global_menu_button_edit.add_style_class("active") + case "global-menu-file": + self.global_menu_button_file.add_style_class("active") + case "global-menu-go": + self.global_menu_button_go.add_style_class("active") + case "global-menu-help": + self.global_menu_button_help.add_style_class("active") + case "global-menu-title": + self.global_menu_button_title.add_style_class("active") + case "global-menu-view": + self.global_menu_button_view.add_style_class("active") + case "global-menu-window": + self.global_menu_button_window.add_style_class("active") + case _: + pass class MenuBar(Box): - def __init__(self, **kwargs): - super().__init__( - name="menubar", - orientation="h", - spacing=0, - **kwargs, - ) + """Main MenuBar widget that contains all menu buttons""" - self.active_window = HyprlandActiveWindow(name="hyprland-window") - self.active_window.connect("window-activated", self.on_window_changed) + def __init__(self, parent_window=None, **kwargs): + # Extract parent_window from kwargs if not provided as parameter + if parent_window is None: + parent_window = kwargs.pop("parent_window", None) - self.current_window_class = None - self.current_window_title = None + super().__init__(name="menubar", orientation="horizontal", spacing=0, **kwargs) - self.current_dropdown = None - self.dropdown_window = None + # Create the dropdown system + self.dropdown_system = MenuBarDropdowns(parent=parent_window) - self.action_handler = MenuActionHandler() - - # Menu items in macOS style order (excluding the first item which will be dynamic) - self.static_menu_items = ["File", "Edit", "View", "Go", "Window", "Help"] - - self.menu_contents = get_default_menu_contents() - - self.app_button = Button( - name="menubar-button", - child=Label(name="activewindow-label", label="Hyprland"), - ) - self.app_button.connect( - "clicked", lambda widget: self.toggle_dropdown("Hyprland", widget) - ) - self.add(self.app_button) - - self.menu_buttons = [] - for item in self.static_menu_items: - button = Button( - name="menubar-button", - child=Label(name="menubar-label", label=item), - ) - - button.connect( - "clicked", - lambda widget, menu_item=item: self.on_menu_button_clicked( - menu_item, widget - ), - ) - self.menu_buttons.append(button) - self.add(button) - - self.update_menu_button_states() - - self.show_all() - - def get_button_for_item(self, menu_item): - if menu_item == "Hyprland": - return self.app_button - - for i, item in enumerate(self.static_menu_items): - if item == menu_item: - return self.menu_buttons[i] - return None - - def on_menu_button_clicked(self, menu_item, button): - # Check if we should allow this menu to be opened - if not self.should_menu_be_active(menu_item): - return - - # Menu is allowed, proceed with normal toggle - self.toggle_dropdown(menu_item, button) - - def should_menu_be_active(self, menu_item): - # These menus should only work when there's an active window - context_dependent_menus = ["File", "Edit", "View", "Go"] - - if menu_item in context_dependent_menus: - # Only allow if there's an active window (not just showing "Hyprland") - has_active_window = ( - self.current_window_title - and self.current_window_title.strip() - and self.app_button.get_child().get_label() != "Hyprland" - ) - return has_active_window - - # Window and Help menus are always available - return True - - def update_menu_button_states(self): - context_dependent_menus = ["File", "Edit", "View", "Go"] - - for i, menu_item in enumerate(self.static_menu_items): - if menu_item in context_dependent_menus: - button = self.menu_buttons[i] - is_active = self.should_menu_be_active(menu_item) - - # Keep buttons sensitive for hovering, but add visual styling for inactive state - # Don't use set_sensitive(False) as it prevents hovering - if is_active: - button.get_style_context().remove_class("inactive") - else: - button.get_style_context().add_class("inactive") - - def toggle_dropdown(self, menu_item, button): - # If same menu is clicked and dropdown is visible, hide it - if ( - self.current_dropdown == menu_item - and self.dropdown_window - and self.dropdown_window.get_visible() - ): - self.hide_dropdown() - return - - # (show_dropdown will handle hiding the previous one) - self.show_dropdown(menu_item, button) - - def show_dropdown(self, menu_item, button): - # Hide existing dropdown if any and wait for it to be completely hidden - if self.dropdown_window: - self.dropdown_window.set_visible(False) - self.dropdown_window.destroy() - self.dropdown_window = None - # Wait a short moment to ensure the previous dropdown is completely gone - GLib.timeout_add(150, self._create_and_show_dropdown, menu_item, button) - else: - # No existing dropdown, show immediately - self._create_and_show_dropdown(menu_item, button) - - def _create_and_show_dropdown(self, menu_item, button): - # Calculate position first - x, y = self.calculate_dropdown_position(button) - - # Create new dropdown window at the calculated position - self.dropdown_window = DropdownWindow(x=x, y=y, parent_menubar=self) - - # Get menu items for this menu - menu_items = self.menu_contents.get(menu_item, []) - - # Add menu items to dropdown - for item in menu_items: - if item == "---": - # Add separator - separator = Box( - name="menu-separator", - size=(150, 1), - style="background-color: #444; margin: 2px 0;", - ) - self.dropdown_window.content_box.add(separator) - else: - # Add menu item button - menu_button = Button( - name="dropdown-item", - child=Label( - name="dropdown-label", - label=item, - h_align="start", # Align text to the left - ), - on_clicked=lambda *_, action=item: self.on_menu_action(action), - ) - self.dropdown_window.content_box.add(menu_button) - - self.dropdown_window.set_size_request(150, -1) - - self.current_dropdown = menu_item - self.dropdown_window.set_visible(True) - - return False - - def calculate_dropdown_position(self, button): - # Get button allocation - allocation = button.get_allocation() - - print( - f"Button allocation: x={allocation.x}, y={allocation.y}, width={allocation.width}, height={allocation.height}" - ) - - # Use the exact button position for perfect alignment - dropdown_x = allocation.x - dropdown_y = allocation.y + 3 # 3px gap below button - - print(f"Perfect dropdown position: x={dropdown_x}, y={dropdown_y}") - - return dropdown_x, dropdown_y - - def hide_dropdown(self): - if self.dropdown_window: - self.dropdown_window.set_visible(False) - self.dropdown_window.destroy() - self.dropdown_window = None - self.current_dropdown = None - - def on_menu_action(self, action): - # Hide dropdown after action - self.hide_dropdown() - - # Handle window management actions - self.execute_menu_action(action) - - def execute_menu_action(self, action): - self.action_handler.execute_action(action) - - def on_window_changed(self, widget, window_class, window_title): - self.current_window_class = window_class - self.current_window_title = window_title - - # Update the first button label and menu contents - if window_title and window_title.strip(): - # Show the window title/app name when there's an active window - display_name = window_class if window_class else window_title - # Capitalize the first letter - display_name = display_name.capitalize() if display_name else "Hyprland" - self.app_button.get_child().set_label(display_name) - - # Update the app menu contents dynamically - self.update_app_menu(display_name) - else: - # Show "Hyprland" when no active window - self.app_button.get_child().set_label("Hyprland") - self.update_app_menu("Hyprland") - - # Update menu button states based on new context - self.update_menu_button_states() - - def update_app_menu(self, app_name): - # Get app-specific menu contents (includes app-specific overrides if available) - self.menu_contents = get_app_specific_menu_contents(app_name) - - def show_system_dropdown(self, button): - # If same system dropdown is open, hide it (toggle behavior) - if ( - self.current_dropdown == "System" - and self.dropdown_window - and self.dropdown_window.get_visible() - ): - self.hide_dropdown() - return - - # Hide existing dropdown if any and show system dropdown - if self.dropdown_window: - self.dropdown_window.set_visible(False) - self.dropdown_window.destroy() - self.dropdown_window = None - # Wait a short moment to ensure the previous dropdown is completely gone - GLib.timeout_add(150, self._create_and_show_system_dropdown, button) - else: - # No existing dropdown, show immediately - self._create_and_show_system_dropdown(button) - - def _create_and_show_system_dropdown(self, button): - # Calculate position first - x, y = self.calculate_dropdown_position(button) - - # Create new dropdown window at the calculated position - self.dropdown_window = DropdownWindow(x=x, y=y, parent_menubar=self) - - # System menu items - system_menu_items = [ - "About This PC", - "---", - "Force Quit", - "---", - "Shutdown", - "Restart", - "Sleep", - "Lock", + # Add all the menu buttons to the menubar + self.children = [ + self.dropdown_system.global_menu_button_title, + self.dropdown_system.global_menu_button_file, + self.dropdown_system.global_menu_button_edit, + self.dropdown_system.global_menu_button_view, + self.dropdown_system.global_menu_button_go, + self.dropdown_system.global_menu_button_window, + self.dropdown_system.global_menu_button_help, ] - # Add menu items to dropdown - for item in system_menu_items: - if item == "---": - # Add separator - separator = Box( - name="menu-separator", - size=(150, 1), - style="background-color: #444; margin: 2px 0;", - ) - self.dropdown_window.content_box.add(separator) - else: - # Add menu item button - menu_button = Button( - name="dropdown-item", - child=Label( - name="dropdown-label", - label=item, - h_align="start", # Align text to the left - ), - on_clicked=lambda *_, action=item: self.on_menu_action(action), - ) - self.dropdown_window.content_box.add(menu_button) - - # Set dropdown window size - self.dropdown_window.set_size_request(150, -1) - - # Show the dropdown without animations - self.current_dropdown = "System" - # Use set_visible(True) for immediate display without animations - self.dropdown_window.set_visible(True) - - # Return False to stop the timeout - return False + def show_system_dropdown(self, _): + """Show the system dropdown menu""" + self.dropdown_system.menu_button_dropdown.toggle_mousecapture() diff --git a/modules/panel/components/recording_indicator.py b/modules/panel/components/recording_indicator.py index e1a92e1..d9fa1e5 100644 --- a/modules/panel/components/recording_indicator.py +++ b/modules/panel/components/recording_indicator.py @@ -9,6 +9,8 @@ from fabric.widgets.button import Button from fabric.widgets.label import Label from fabric.widgets.svg import Svg +# FIX: Make the timer consistent with the recording start time + class RecordingIndicator(Button): def __init__(self, **kwargs): @@ -26,14 +28,14 @@ class RecordingIndicator(Button): name="recording-time-label", markup="00:00", max_width_chars=5, - ellipsize="none" + ellipsize="none", ) self.recording_box = Box( orientation="h", spacing=2, children=[self.recording_icon, self.time_label], - size=(80, -1), + size=(80, -1), ) self.add(self.recording_box) diff --git a/modules/panel/main.py b/modules/panel/main.py index 95cea33..7e4af5b 100644 --- a/modules/panel/main.py +++ b/modules/panel/main.py @@ -7,14 +7,19 @@ 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 utils.wayland import WaylandWindow as Window +from widgets.wayland import WaylandWindow as Window from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray -from modules.panel.components.indicators import Indicators +from modules.panel.components.indicators import ( + BluetoothIndicator, + NetworkIndicator, + BatteryIndicator, +) from modules.panel.components.menubar import MenuBar from modules.panel.components.recording_indicator import RecordingIndicator -from utils.roam import modus_service -from modules.notification import EnvNoti + +from modules.controlcenter.main import ModusControlCenter +from widgets.mousecapture import MouseCapture # Apply enhanced system tray icon handling apply_enhanced_system_tray() @@ -24,6 +29,7 @@ class Panel(Window): def __init__(self, **kwargs): super().__init__( name="bar", + title="modus", layer="top", anchor="left top right", exclusivity="auto", @@ -31,7 +37,7 @@ class Panel(Window): ) self.launcher = kwargs.get("launcher", None) - self.menubar = MenuBar() + self.menubar = MenuBar(parent_window=self) self.imac = Button( name="panel-button", @@ -41,18 +47,9 @@ class Panel(Window): ), on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac), ) - self.notch_spot = Box( - name="notch-spot", - size=(200, 24), - h_expand=True, - v_expand=True, - children=Label(label="notch"), - ) self.tray = SystemTray(name="system-tray", spacing=4, icon_size=20) - # modus_service.connect("show-notificationcenter-changed", envnoticenter.toggle_mousecapture) - self.tray_revealer = Revealer( name="tray-revealer", child=self.tray, @@ -72,26 +69,42 @@ class Panel(Window): on_clicked=self.toggle_tray, ) - self.indicators = Indicators() + self.indicators = Box( + name="indicators", + orientation="h", + spacing=4, + children=[ + BatteryIndicator(), + NetworkIndicator(), + BluetoothIndicator(), + ], + ) self.search = Button( name="panel-button", on_clicked=lambda *_: self.search_apps(), child=Svg( - size=20, + size=22, svg_file=get_relative_path("../../config/assets/icons/search.svg"), ), ) - self.controlcenter = Button( - name="panel-button", + self.control_center = MouseCapture( + layer="top", child_window=ModusControlCenter() + ) + + self.control_center_button = Button( + name="control-center-button", + style_classes="button", + on_clicked=self.control_center.toggle_mousecapture, child=Svg( - size=24, + size=25, svg_file=get_relative_path( "../../config/assets/icons/control-center.svg" ), ), ) + self.recording_indicator = RecordingIndicator() self.children = CenterBox( @@ -103,21 +116,20 @@ class Panel(Window): self.menubar, ], ), - # center_children=Box( - # name="modules-center", - # children=self.notch_spot, - # ), + center_children=Box( + name="modules-center", + children=self.recording_indicator, + ), end_children=Box( name="modules-right", spacing=4, orientation="h", children=[ - self.recording_indicator, self.tray_revealer, self.chevron_button, self.indicators, self.search, - self.controlcenter, + self.control_center_button, DateTime(name="date-time", formatters=["%a %b %d %I:%M"]), ], ), diff --git a/modules/switcher.py b/modules/switcher.py index b0b8737..ee7855e 100644 --- a/modules/switcher.py +++ b/modules/switcher.py @@ -8,7 +8,7 @@ from fabric.widgets.image import Image from fabric.widgets.label import Label from gi.repository import Gdk from utils.icon_resolver import IconResolver -from utils.wayland import WaylandWindow as Window +from widgets.wayland import WaylandWindow as Window class ApplicationSwitcher(Window): diff --git a/services/modus.py b/services/modus.py index 27ced60..4ed4a85 100644 --- a/services/modus.py +++ b/services/modus.py @@ -1,19 +1,81 @@ -# type: ignore +from loguru import logger from fabric.core.service import Service, Signal, Property from fabric.notifications import Notification from typing import List class ModusService(Service): + @Signal + def bluetooth_changed(self, new_bluetooth: str) -> None: ... + + @Signal + def volume_changed(self, new_volume: int) -> None: ... + + @Signal + def wlan_changed(self, new_wlan: str) -> None: ... + + @Signal + def battery_changed(self, new_battery: str) -> None: ... + + @Signal + def dock_apps_changed(self, new_dock_apps: str) -> None: ... + + @Signal + def clear_all_changed(self, value: bool) -> None: ... + @Signal def notification_count_changed(self, value: int) -> None: ... + @Signal def dont_disturb_changed(self, value: bool) -> None: ... + + @Signal + def current_active_app_name_changed(self, value: str) -> None: ... + + @Signal + def music_changed(self, value: str) -> None: ... + + @Signal + def current_dropdown_changed(self, value: str) -> None: ... + + @Signal + def dropdowns_hide_changed(self, value: bool) -> None: ... + + @Signal + def dock_width_changed(self, value: int) -> None: ... + + @Signal + def dock_height_changed(self, value: int) -> None: ... + + @Signal + def dock_hidden_changed(self, value: bool) -> None: ... + @Signal def show_notificationcenter_changed(self, value: bool) -> None: ... - @Property(bool, flags="read-write", default_value=False) - def show_notificationcenter(self) -> bool: - return self._show_notificationcenter + + @Property(str, flags="read-write") + def current_active_app_name(self) -> str: + return self._current_active_app_name + + @Property(str, flags="read-write") + def bluetooth(self) -> str: + return self._bluetooth + + @Property(str, flags="read-write") + def wlan(self) -> str: + return self._wlan + + @Property(str, flags="read-write") + def battery(self) -> str: + return self._battery + + @Property(int, flags="read-write") + def volume(self) -> int: + return self._volume + + @Property(str, flags="read-write") + def dock_apps(self) -> str: + return self._dock_apps @Property(str, flags="read-write") def notification_count(self) -> int: @@ -23,12 +85,112 @@ class ModusService(Service): def dont_disturb(self) -> bool: return self._dont_disturb + @Property(str, flags="read-write") + def music(self) -> str: + return self._music + + @Property(str, flags="read-write") + def current_dropdown(self) -> str: + return self._current_dropdown + + @Property(bool, flags="read-write", default_value=False) + def dropdowns_hide(self) -> bool: + return self._dropdowns_hide + + @Property(int, flags="read-write") + def dock_width(self) -> int: + return self._dock_width + + @Property(int, flags="read-write") + def dock_height(self) -> int: + return self._dock_height + + @Property(bool, flags="read-write", default_value=False) + def dock_hidden(self) -> bool: + return self._dock_hidden + + @Property(bool, flags="read-write", default_value=False) + def show_notificationcenter(self) -> bool: + return self._show_notificationcenter + + @current_active_app_name.setter + def current_active_app_name(self, value: str): + if value != self._current_active_app_name: + self._current_active_app_name = value + self.current_active_app_name_changed(value) + + @volume.setter + def volume(self, value: int): + if value != self._volume: + self._name = value + self.volume_changed(value) + + @wlan.setter + def wlan(self, value: str): + if value != self._wlan: + self._wlan = value + self.wlan_changed(value) + + @battery.setter + def battery(self, value: str): + if value != self._battery: + self._battery = value + self.battery_changed(value) + + @bluetooth.setter + def bluetooth(self, value: str): + if value != self._bluetooth: + self._bluetooth = value + self.bluetooth_changed(value) + + @dock_apps.setter + def dock_apps(self, value: str): + if value != self._dock_apps: + self._dock_apps = value + self.dock_apps_changed(value) + @dont_disturb.setter def dont_disturb(self, value: bool): if value != self._dont_disturb: self._dont_disturb = value self.dont_disturb_changed(value) + @music.setter + def music(self, value: str): + if value != self._music: + self._music = value + self.music_changed(value) + + @current_dropdown.setter + def current_dropdown(self, value: str): + if value != self._current_dropdown: + self._current_dropdown = value + self.current_dropdown_changed(value) + + @dropdowns_hide.setter + def dropdowns_hide(self, value: bool): + if value != self._dropdowns_hide: + self._dropdowns_hide = value + self.dropdowns_hide_changed(value) + + @dock_width.setter + def dock_width(self, value: int): + if value != self._dock_width: + self._dock_width = value + self.dock_width_changed(value) + + @dock_height.setter + def dock_height(self, value: int): + if value != self._dock_height: + self._dock_height = value + self.dock_height_changed(value) + + @dock_hidden.setter + def dock_hidden(self, value: bool): + if value != self._dock_hidden: + self._dock_hidden = value + self.dock_hidden_changed(value) + @show_notificationcenter.setter def show_notificationcenter(self, value: bool): if value != self._show_notificationcenter: @@ -41,11 +203,24 @@ class ModusService(Service): def __init__(self): super().__init__() + self._volume = 0 + self._wlan = "" + self._battery = "" + self._bluetooth = "" + self._dock_apps = "" self._notifications = [] self._notification_count = len(self._notifications) self._dont_disturb = False + self._current_active_app_name = "" + self._music = "" + self._current_dropdown = None + self._dropdowns_hide = False + self._dock_hidden = False self._show_notificationcenter = False + self._dock_width = 0 + self._dock_height = 0 + self.notifications = [] def remove_notification(self, id: int): @@ -83,3 +258,10 @@ class ModusService(Service): Notification.deserialize(data) for data in self._notifications ] return self.notifications + + +global modus_service +try: + modus_service = ModusService() +except Exception as e: + logger.error("[Main] Failed to create EnvShellService:", e) diff --git a/services/network.py b/services/network.py index 54016d9..1113b3f 100644 --- a/services/network.py +++ b/services/network.py @@ -1,301 +1,17 @@ -from typing import Optional +import subprocess +from typing import Any, List, Literal import gi from fabric.core.service import Property, Service, Signal -from fabric.utils import bulk_connect, get_enum_member_name, snake_case_to_kebab_case -from gi.repository import NM, GLib +from fabric.utils import bulk_connect +from gi.repository import Gio from loguru import logger -gi.require_version("NM", "1.0") # Ensure the correct version is loaded - - -class NetworkClient(Service): - """A service to manage network devices""" - - @Signal - def wifi_device_added(self) -> None: ... - - @Signal - def ethernet_device_added(self) -> None: ... - - @Signal - def wifi_device_removed(self) -> None: ... - - @Signal - def ethernet_device_removed(self) -> None: ... - - @Signal - def changed(self) -> None: ... - - @Signal - def ready(self) -> None: ... - - @Property(bool, "readable", default_value=False) - def is_ready(self) -> bool: - """Returns whether the client is ready to use.""" - return self._client is not None - - @Property(list, "readable") - def connections(self) -> Optional[list]: - """Returns the active connections, if available.""" - return self._client.get_property("connections") if self._client else None - - @Property(object, "readable") - def wifi_device(self) -> Optional[object]: - """Returns the WiFi device if available.""" - return self._wifi_device - - @Property(object, "readable") - def ethernet_device(self) -> Optional[object]: - """Returns the Ethernet device if available.""" - return self._ethernet_device - - @Property(str, "readable") - def primary_connection(self) -> Optional[str]: - """Returns the primary connection if available.""" - return self._client.get_property("primary_connection") if self._client else None - - @Property(str, "readable") - def active_connection(self) -> Optional[str]: - """Returns the active connection if available.""" - return self._client.get_property("active_connection") if self._client else None - - @Property(str, "readable") - def state(self) -> str: - """Returns the current network state.""" - if not self._client: - return "disconnected" - return snake_case_to_kebab_case( - get_enum_member_name( - self._client.get_property("state"), default="disconnected" - ) - ) - - @Property(str, "readable") - def connectivity(self) -> str: - """Returns the connectivity state.""" - if not self._client: - return "disconnected" - return snake_case_to_kebab_case( - get_enum_member_name( - self._client.get_property("connectivity"), default="disconnected" - ) - ) - - @Property(list, "readable") - def devices(self) -> Optional[list]: - """Returns the list of network devices if available.""" - return self._client.get_property("devices") if self._client else None - - @Property(str, "readable") - def hostname(self) -> Optional[str]: - """Returns the hostname if available.""" - return self._client.get_property("hostname") if self._client else None - - @Property(bool, "read-write", default_value=False) - def networking_enabled(self) -> bool: - """Checks if networking is enabled.""" - return ( - self._client.get_property("networking_enabled") if self._client else False - ) - - @networking_enabled.setter - def networking_enabled(self, value: bool): - """Sets the networking state if the client is available.""" - if self._client: - self._client.set_property("networking_enabled", value) - - @Property(bool, "read-write", default_value=False) - def wireless_enabled(self) -> bool: - """Checks if wireless networking is enabled.""" - return self._client.get_property("wireless_enabled") if self._client else False - - @wireless_enabled.setter - def wireless_enabled(self, value: bool): - """Sets the wireless networking state if the client is available.""" - if self._client: - self._client.set_property("wireless_enabled", value) - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - self._client: NM.Client = None - self._wifi_device: Wifi | None = None - self._ethernet_device: Ethernet | None = None - - logger.info("[Network] Initializing client asynchronously...") - - # Start async NM client initialization - NM.Client.new_async(None, self.on_client_ready) - - def on_client_ready(self, source, result): - """Callback when NM.Client is ready.""" - try: - self._client = NM.Client.new_finish(result) # Retrieve client instance - logger.info("[Network] NM.Client initialized successfully!") - - # Connect signals - bulk_connect( - self._client, - { - "device-added": lambda _, device: self.on_device_added( - device=device - ), - "device-removed": lambda _, device: self.on_device_removed( - device=device - ), - "notify::state": lambda *args: self.notifier("state"), - "notify::networking-enabled": lambda *args: self.notifier( - "networking-enabled" - ), - "notify::wireless-enabled": lambda *args: self.notifier( - "wireless-enabled" - ), - # "notify::primary-connection": lambda *args: self.notifier('primary-connection'), - # "notify::active-connection": lambda *args: self.notifier('active-connection'), - # "active-connection-added": lambda *args: self.emit("changed"), - # "active-connection-removed": lambda *args: self.emit("changed") - }, - ) - - # Process devices AFTER client is ready - for device in self.do_get_raw_devices(): - self.on_device_added(device=device) - - self.notify("state") - self.notify("networking-enabled") - self.notify("wireless-enabled") - self.notify("is-ready") - self.ready.emit() - - except Exception as e: - logger.error(f"[Network] Error initializing NM.Client: {e}") - - def do_get_raw_devices(self) -> list[NM.Device]: - return [ - dev - for dev in self.devices - if dev.get_device_type() in (NM.DeviceType.WIFI, NM.DeviceType.ETHERNET) - ] - - def on_device_added(self, device): - device_type = device.get_device_type() - if device_type == NM.DeviceType.WIFI and not self._wifi_device: - logger.info("[Network] WiFi device detected, initializing...") - self._wifi_device = Wifi(client=self, device=device) - self.wifi_device_added.emit() - - elif device_type == NM.DeviceType.ETHERNET and not self._ethernet_device: - logger.info("[Network] Ethernet device detected, initializing...") - self._ethernet_device = Ethernet(client=self, device=device) - self.ethernet_device_added.emit() - - def on_device_removed(self, device): - if device == self._wifi_device: - logger.info("[Network] WiFi device removed.") - self._wifi_device = None - self.wifi_device_removed.emit() - - elif device == self._ethernet_device: - logger.info("[Network] Ethernet device removed.") - self._ethernet_device = None - self.ethernet_device_removed.emit() - - def toggle_network(self): - """Enable or disable Network""" - self.networking_enabled = not self.networking_enabled - - def deactivate_connection(self, connection): - """Disconnect""" - self._client.deactivate_connection_async(connection, None, None) - - def notifier(self, name): - self.notify(name) - self.emit("changed") - - -class AccessPoint(Service): - """A service to manage access points""" - - @Signal - def changed(self) -> None: ... - - @Property(object, "readable") - def device(self) -> object: - return self._device - - @Property(int, "readable") - def strength(self) -> int: - return self._ap.get_property("strength") - - @Property(int, "readable") - def frequency(self) -> int: - return self._ap.get_property("frequency") - - @Property(str, "readable") - def bssid(self) -> str: - return self._ap.get_property("bssid") if self._ap else None - - @Property(str, "readable") - def hw_address(self) -> str: - return self._ap.get_property("hw_address") - - @Property(str, "readable") - def ssid(self) -> str: - ssid = self._ap.get_ssid() - return NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - @Property(str, "readable") - def icon(self) -> str: - return { - 80: "network-wireless-signal-excellent-symbolic", - 60: "network-wireless-signal-good-symbolic", - 40: "network-wireless-signal-ok-symbolic", - 20: "network-wireless-signal-weak-symbolic", - 00: "network-wireless-signal-none-symbolic", - }.get( - min(80, 20 * round(self.strength / 20)), - "network-wireless-no-route-symbolic", - ) - - @Property(bool, "readable", default_value=False) - def requires_password(self) -> bool: - ssid = self.ssid - settings = self._client.connections - connection = None - for setting in settings: - wifi_setting = setting.get_setting_wireless() - if ( - wifi_setting - and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid - ): - connection = setting - break - if not connection: - return bool(self._ap.get_wpa_flags() or self._ap.get_rsn_flags()) - return False - - @Property(bool, "readable", default_value=False) - def is_active(self) -> bool: - if self._device.active_access_point: - return self.bssid == self._device.active_access_point.get_bssid() - return False - - def __init__(self, device: "Wifi", ap: NM.AccessPoint, **kwargs): - super().__init__(**kwargs) - self._client: NetworkClient = device.client - self._device: Wifi = device - self._ap: NM.AccessPoint = ap - - self._ap.connect("notify::strength", lambda *args: self.notifier("strength")) - self._device.connect( - "notify::active-access-point", lambda *args: self.notifier("is-active") - ) - - def notifier(self, name: str, *args): - self.notify(name) - self.emit("changed") - return +try: + gi.require_version("NM", "1.0") + from gi.repository import NM +except ValueError: + raise logger.error("Failed to start network manager") class Wifi(Service): @@ -305,244 +21,286 @@ class Wifi(Service): def changed(self) -> None: ... @Signal - def ap_added(self, ap: AccessPoint) -> None: ... + def enabled(self) -> bool: ... @Signal - def ap_removed(self, ap: AccessPoint) -> None: ... + def scanning(self, is_scanning: bool) -> None: ... - @Property(NetworkClient, "readable") - def client(self) -> NetworkClient: - """Returns the client""" - return self._client - - @Property(bool, "read-write", default_value=False) - def wireless_enabled(self) -> bool: - """Returns if the wifi is enabled""" - return self._client.get_property("wireless_enabled") - - @wireless_enabled.setter - def wireless_enabled(self, value: bool): - return self._client.set_property("wireless_enabled", value) - - @Property(list[AccessPoint], "readable") - def access_points(self) -> list[AccessPoint]: - return sorted( - self._access_points.values(), key=lambda x: x.is_active, reverse=True - ) - - @Property(AccessPoint, "readable") - def active_access_point(self) -> Optional[AccessPoint]: - return self._active_access_point - - def __init__(self, client: NetworkClient, device: NM.DeviceWifi, **kwargs): - super().__init__(**kwargs) - self._client: NetworkClient = client + def __init__(self, client: NM.Client, device: NM.DeviceWifi, **kwargs): + self._client: NM.Client = client self._device: NM.DeviceWifi = device - self._active_access_point: NM.AccessPoint | None = None - self._access_points: dict[str, AccessPoint] = {} - - bulk_connect( - self._device, - { - "notify::active-access-point": lambda *args: self.on_access_point_activated(), - "access-point-added": lambda _, ap: self.on_access_point_added(ap=ap), - "access-point-removed": lambda _, ap: self.on_access_point_removed( - ap=ap - ), - # "state-changed": lambda device, new, old, reason: self.on_state_changed(new), - }, - ) + self._ap: NM.AccessPoint | None = None + self._ap_signal: int | None = None + super().__init__(**kwargs) self._client.connect( - "notify::wireless-enabled", lambda *args: self.notifier("wireless-enabled") + "notify::wireless-enabled", + lambda *args: self.notifier("enabled", args), ) + if self._device: + bulk_connect( + self._device, + { + "notify::active-access-point": self._activate_ap, + "access-point-added": lambda *_: self.emit("changed"), + "access-point-removed": lambda *_: self.emit("changed"), + "state-changed": self.ap_update, + }, + ) + self._activate_ap() - for ap in self.do_get_access_points(): - self.on_access_point_added(ap=ap) - - self.on_access_point_activated() - - def on_state_changed(self, state): + def ap_update(self, *_): self.emit("changed") + for sn in [ + "enabled", + "internet", + "strength", + "frequency", + "access-points", + "ssid", + "state", + "icon-name", + ]: + self.notify(sn) - def do_get_access_points(self): - return self._device.get_access_points() + def _activate_ap(self, *_): + if self._ap: + self._ap.disconnect(self._ap_signal) + self._ap = self._device.get_active_access_point() + if not self._ap: + return - def on_access_point_added(self, ap): - ssid = ap.get_ssid() - ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - access_point: AccessPoint = AccessPoint(ap=ap, device=self) - - self._access_points[ssid] = access_point - - self.ap_added.emit(access_point) - - # self.notifier("access-points") - - logger.info(f"[Wifi] New access point added with ssid: {ssid}") - - def on_access_point_removed(self, ap): - ssid = ap.get_ssid() - ssid = NM.utils_ssid_to_utf8(ssid.get_data()) if ssid else "Unknown" - - if not (access_point := self._access_points.pop(ssid, None)): - return logger.warning( - f"[Network] tried to remove a unknwon access point with ssid {ssid}" - ) - - self.ap_removed.emit(access_point) - - logger.info(f"[Wifi] Access point with ssid: {ssid} removed.") - - def on_access_point_activated(self): - if self._device.get_active_access_point(): - self._active_access_point: AccessPoint = AccessPoint( - ap=self._device.get_active_access_point(), device=self - ) - - else: - self._active_access_point = None - - self.notifier("active-access-point") - - logger.info("[Wifi] New active connection") - - def disconnect_wifi(self): - """Disconnect from the current WiFi network.""" - active_connection = self._device.get_active_connection() - self._client.deactivate_connection(active_connection) - logger.info("[Wifi] Wifi network disconnected") - - def scan(self): - self._device.request_scan_async( - None, - lambda device, result: [ - device.request_scan_finish(result), - self.notifier("access-points"), - ], - ) - logger.info("[Wifi] Scan started") + self._ap_signal = self._ap.connect( + "notify::strength", lambda *args: self.ap_update() + ) # type: ignore def toggle_wifi(self): - """Enable or disable WiFi""" - self.wireless_enabled = not self.wireless_enabled + self._client.wireless_set_enabled(not self._client.wireless_get_enabled()) - def connect_to_wifi(self, ap: AccessPoint, password: str = None): - """Connect to a WiFi network.""" + def get_ap_security( + self, nm_ap: NM.AccessPoint + ) -> Literal["WEP", "WPA1", "WPA2", "802.1X", "unsecured"]: + """Parse the security flags to return a string with 'WPA2', etc.""" + flags = nm_ap.get_flags() + wpa_flags = nm_ap.get_wpa_flags() + rsn_flags = nm_ap.get_rsn_flags() + sec_str = "" + if ( + (flags & getattr(NM, "80211ApFlags").PRIVACY) + and (wpa_flags == 0) + and (rsn_flags == 0) + ): + sec_str += " WEP" + if wpa_flags != 0: + sec_str += " WPA1" + if rsn_flags != 0: + sec_str += " WPA2" + if (wpa_flags & getattr(NM, "80211ApSecurityFlags").KEY_MGMT_802_1X) or ( + rsn_flags & getattr(NM, "80211ApSecurityFlags").KEY_MGMT_802_1X + ): + sec_str += " 802.1X" - ssid = ap.ssid + # If there is no security use "--" + if sec_str == "": + sec_str = "unsecured" + return sec_str.lstrip() - if ssid == "Unknown": - logger.error("Invalid access point data") - return False - - logger.info(f"Connecting to WiFi SSID: {ssid}") - - # Check for existing connections - settings = self._client.connections - connection = None - - for setting in settings: - wifi_setting = setting.get_setting_wireless() - if ( - wifi_setting - and NM.utils_ssid_to_utf8(wifi_setting.get_ssid().get_data()) == ssid - ): - connection = setting - break - - if not connection: - # Create a new connection profile - logger.info(f"Creating new WiFi connection for SSID '{ssid}'") - connection = NM.SimpleConnection.new() - - # Required connection settings - s_con = NM.SettingConnection.new() - s_con.set_property(NM.SETTING_CONNECTION_ID, ssid) - s_con.set_property(NM.SETTING_CONNECTION_TYPE, "802-11-wireless") - s_con.set_property( - NM.SETTING_CONNECTION_INTERFACE_NAME, self._device.get_iface() - ) # Set interface name - connection.add_setting(s_con) - - # Wireless settings - s_wifi = NM.SettingWireless.new() - s_wifi.set_property(NM.SETTING_WIRELESS_SSID, GLib.Bytes.new(ssid.encode())) - s_wifi.set_property( - NM.SETTING_WIRELESS_MODE, "infrastructure" - ) # Ensure mode is correct - connection.add_setting(s_wifi) - - # Security settings (only if password is required and provided) - if ap.requires_password: - if not password: - logger.error("Password required but not provided") - return False - - s_sec = NM.SettingWirelessSecurity.new() - s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_KEY_MGMT, "wpa-psk") - s_sec.set_property(NM.SETTING_WIRELESS_SECURITY_PSK, password) - connection.add_setting(s_sec) - - # IPv4 settings - s_ipv4 = NM.SettingIP4Config.new() - s_ipv4.set_property("method", "auto") - connection.add_setting(s_ipv4) - - # IPv6 settings - s_ipv6 = NM.SettingIP6Config.new() - s_ipv6.set_property("method", "auto") - connection.add_setting(s_ipv6) - - # Callback for async connection - def on_connection_added(client, result, ssid, device): - try: - new_connection = client.add_connection_finish(result) - if not new_connection: - logger.error(f"Failed to create connection for '{ssid}'") - return - - logger.info(f"Connection for '{ssid}' added successfully") - - client.activate_connection_async( - new_connection, - device, - None, - None, - lambda c, r: ( - logger.info(f"Connected to '{ssid}'") - if c - else logger.error(f"Failed to connect to '{ssid}'") - ), - ) - except Exception as e: - logger.error(f"Failed to add connection: {e}") - - # Save the new connection - self._client._client.add_connection_async( - connection, - True, + def scan(self): + """Start scanning for WiFi networks and emit scanning signal""" + if self._device: + self.emit("scanning", True) # Emit signal that scanning has started + self._device.request_scan_async( None, - lambda c, r: on_connection_added(c, r, ssid, self._device), + lambda device, result: [ + device.request_scan_finish(result), + self.emit( + "scanning", False + ), # Emit signal that scanning has stopped + ], ) - # Activate the connection - self._client._client.activate_connection_async( - connection, - self._device, - None, - None, - lambda client, result: logger.info(f"Connected to '{ssid}'"), - ) + def is_active_ap(self, name) -> bool: + return self._ap.get_bssid() == name if self._ap else False - return True - - def notifier(self, name: str, *args): + def notifier(self, name: str, *_): self.notify(name) self.emit("changed") return + def forget_access_point(self, ssid): + try: + # List all saved connections + result = subprocess.check_output( + "nmcli connection show", shell=True, text=True + ) + + # Find connection ID that matches SSID + for line in result.splitlines(): + if ssid in line: + connection_id = line.split()[0] + subprocess.check_call( + f"nmcli connection delete id '{connection_id}'", shell=True + ) + logger.info( + f"[NetworkService] Deleted saved connection: {connection_id}" + ) + return True + + logger.warning( + f"[NetworkService] No saved connection found for SSID: {ssid}" + ) + return False + + except subprocess.CalledProcessError as e: + logger.error(f"[NetworkService] Error forgetting connection: {e}") + return False + + def connect_network( + self, ssid: str, password: str = "", remember: bool = True + ) -> bool: + """Connect to a WiFi network""" + if not ssid: + logger.error("[NetworkService] SSID cannot be empty") + return False + try: + # First try to connect using saved connection + try: + subprocess.run(["nmcli", "con", "up", ssid], check=True) + return True + except subprocess.CalledProcessError: + # If saved connection fails, try with password if provided + if password: + cmd = [ + "nmcli", + "device", + "wifi", + "connect", + ssid, + "password", + password, + ] + if not remember: + cmd.extend(["--temporary"]) + subprocess.run(cmd, check=True) + return True + return False + except subprocess.CalledProcessError as e: + logger.error(f"[NetworkService] Failed connecting to network: {e}") + return False + + def disconnect_network(self, ssid: str) -> bool: + """Disconnect from a WiFi network""" + if not ssid: + logger.error("[NetworkService] SSID cannot be empty") + return False + try: + subprocess.run(["nmcli", "con", "down", ssid], check=True) + return True + except subprocess.CalledProcessError as e: + logger.error(f"[NetworkService] Failed disconnecting from network: {e}") + return False + + @Property(bool, "read-write", default_value=False) + def enabled(self) -> bool: # noqa: F811 + return bool(self._client.wireless_get_enabled()) + + @enabled.setter + def enabled(self, value: bool): + self._client.wireless_set_enabled(value) + + @Property(int, "readable") + def strength(self): + return self._ap.get_strength() if self._ap else -1 + + @Property(str, "readable") + def icon_name(self): + if not self._ap: + return "network-wireless-disabled-symbolic" + + if self.internet == "activated": + return { + 80: "network-wireless-signal-excellent-symbolic", + 60: "network-wireless-signal-good-symbolic", + 40: "network-wireless-signal-ok-symbolic", + 20: "network-wireless-signal-weak-symbolic", + 00: "network-wireless-signal-none-symbolic", + }.get( + min(80, 20 * round(self._ap.get_strength() / 20)), + "network-wireless-no-route-symbolic", + ) + if self.internet == "activating": + return "network-wireless-acquiring-symbolic" + + return "network-wireless-offline-symbolic" + + @Property(int, "readable") + def frequency(self): + return self._ap.get_frequency() if self._ap else -1 + + @Property(int, "readable") + def internet(self): + return { + NM.ActiveConnectionState.ACTIVATED: "activated", + NM.ActiveConnectionState.ACTIVATING: "activating", + NM.ActiveConnectionState.DEACTIVATING: "deactivating", + NM.ActiveConnectionState.DEACTIVATED: "deactivated", + }.get( + self._device.get_active_connection().get_state(), + "unknown", + ) + + @Property(object, "readable") + def access_points(self) -> List[object]: + points: list[NM.AccessPoint] = self._device.get_access_points() + + def make_ap_dict(ap: NM.AccessPoint): + 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", + "active-ap": self._ap, + "strength": ap.get_strength(), + "frequency": ap.get_frequency(), + "icon-name": { + 80: "network-wireless-signal-excellent-symbolic", + 60: "network-wireless-signal-good-symbolic", + 40: "network-wireless-signal-ok-symbolic", + 20: "network-wireless-signal-weak-symbolic", + 00: "network-wireless-signal-none-symbolic", + }.get( + min(80, 20 * round(ap.get_strength() / 20)), + "network-wireless-no-route-symbolic", + ), + } + + return list(map(make_ap_dict, points)) + + @Property(str, "readable") + def ssid(self): + if not self._ap: + return "Disconnected" + ssid = self._ap.get_ssid().get_data() + return NM.utils_ssid_to_utf8(ssid) if ssid else "Unknown" + + @Property(int, "readable") + def state(self): + return { + NM.DeviceState.UNMANAGED: "unmanaged", + NM.DeviceState.UNAVAILABLE: "unavailable", + NM.DeviceState.DISCONNECTED: "disconnected", + NM.DeviceState.PREPARE: "prepare", + NM.DeviceState.CONFIG: "config", + NM.DeviceState.NEED_AUTH: "need_auth", + NM.DeviceState.IP_CONFIG: "ip_config", + NM.DeviceState.IP_CHECK: "ip_check", + NM.DeviceState.SECONDARIES: "secondaries", + NM.DeviceState.ACTIVATED: "activated", + NM.DeviceState.DEACTIVATING: "deactivating", + NM.DeviceState.FAILED: "failed", + }.get(self._device.get_state(), "unknown") + class Ethernet(Service): """A service to manage ethernet devices""" @@ -554,80 +312,125 @@ class Ethernet(Service): def enabled(self) -> bool: ... @Property(int, "readable") - def speed(self) -> str: - speed_mbps = self._device.get_speed() - return f"{speed_mbps} Mb/s" - - @Property(str, "readable") - def state(self) -> str: - return snake_case_to_kebab_case( - get_enum_member_name(self._device.get_state(), default="disconnected") - ) + def speed(self) -> int: + return self._device.get_speed() @Property(str, "readable") def internet(self) -> str: - if self._active_connection: - return snake_case_to_kebab_case( - get_enum_member_name( - self._active_connection.get_state(), default="disconnected" - ) - ) - return "disconnected" - - @Property(str, "readable") - def iface(self) -> str: - return self._device.get_iface() if self._device else None + return { + NM.ActiveConnectionState.ACTIVATED: "activated", + NM.ActiveConnectionState.ACTIVATING: "activating", + NM.ActiveConnectionState.DEACTIVATING: "deactivating", + NM.ActiveConnectionState.DEACTIVATED: "deactivated", + }.get( + self._device.get_active_connection().get_state(), + "disconnected", + ) @Property(str, "readable") def icon_name(self) -> str: network = self.internet if network == "activated": return "network-wired-symbolic" + elif network == "activating": return "network-wired-acquiring-symbolic" + + elif self._device.get_connectivity != NM.ConnectivityState.FULL: + return "network-wired-no-route-symbolic" + return "network-wired-disconnected-symbolic" def __init__(self, client: NM.Client, device: NM.DeviceEthernet, **kwargs) -> None: super().__init__(**kwargs) self._client: NM.Client = client self._device: NM.DeviceEthernet = device - self._active_connection = None - self._device.connect( - "state-changed", lambda *args: self.on_network_state_changed() + for names in ( + "active-connection", + "icon-name", + "internet", + "speed", + "state", + ): + self._device.connect(f"notify::{names}", lambda *_: self.notifier(names)) + + self._device.connect("notify::speed", lambda *_: print(_)) + + def notifier(self, names): + self.notify(names) + self.emit("changed") + + +class NetworkService(Service): + """A service to manage network devices""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @Signal + def device_ready(self) -> None: ... + + def __init__(self, **kwargs): + self._client: NM.Client | None = None + 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, + **kwargs, ) - self.update_active_connection() + def _init_network_client(self, client: NM.Client, task: Gio.Task, **kwargs): + self._client = client + wifi_device: NM.DeviceWifi | None = self._get_device(NM.DeviceType.WIFI) # type: ignore + ethernet_device: NM.DeviceEthernet | None = self._get_device( + NM.DeviceType.ETHERNET + ) - def on_network_state_changed(self): - """Called when networking is toggled on/off.""" - if self.state != "unmanaged": - # Re-initialize device when networking is re-enabled - self.update_active_connection() - else: - self._active_connection = None + if wifi_device: + self.wifi_device = Wifi(self._client, wifi_device) + self.emit("device-ready") - def update_active_connection(self): - """Updates the active connection and connects to its state change signal.""" - active_connection = self._device.get_active_connection() - if active_connection: - self._active_connection = active_connection - self._active_connection.connect( - "state-changed", lambda *args: self.emit("changed") - ) + if ethernet_device: + self.ethernet_device = Ethernet(client=self._client, device=ethernet_device) + self.emit("device-ready") - def get_network_stats(self): - """Fetch received and transmitted bytes from the system files""" - try: - # Read data from /sys/class/net for accurate speed - with open( - f"/sys/class/net/{self.iface}/statistics/rx_bytes", "r" - ) as rx_file: - rx_bytes = int(rx_file.read().strip()) - with open( - f"/sys/class/net/{self.iface}/statistics/tx_bytes", "r" - ) as tx_file: - tx_bytes = int(tx_file.read().strip()) - return rx_bytes, tx_bytes - except FileNotFoundError: - return None, None + self.notify("primary-device") + + def _get_device(self, device_type) -> Any: + devices: List[NM.Device] = self._client.get_devices() # type: ignore + return next( + ( + x + for x in devices + if x.get_device_type() == device_type + and x.get_active_connection() is not None + ), + None, + ) + + def _get_primary_device(self) -> Literal["wifi", "wired"] | None: + if not self._client: + return None + + if self._client.get_primary_connection() is None: + return "wifi" + return ( + "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 + ) + + @Property(str, "readable") + def primary_device(self) -> Literal["wifi", "wired"] | None: + return self._get_primary_device() \ No newline at end of file diff --git a/styles/controlcenter.css b/styles/controlcenter.css new file mode 100644 index 0000000..cc91a21 --- /dev/null +++ b/styles/controlcenter.css @@ -0,0 +1,166 @@ +#control-center-menu { + background-color: transparent; + border-radius: 15px; + box-shadow: none; + margin: 0; +} + +#control-center-widgets { + background-color: alpha(#000, 0.3); + box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); + border: 1px solid alpha(#111, 0.4); + border-radius: 1.25rem; + padding: 0.5rem; +} + +/* Widgets */ +#wifi-widget, +#bluetooth-widget { + padding: 10px; +} + +.icon { + background-color: #2369ff; + font-size: 15px; + padding: 20px 20px; +} + +#bluetooth-widget-label, +#wifi-widget-label { + font-size: 10px; + margin-left: 5px; + font-weight: 400; + color: #aaa; +} + +#bluetooth-widget-title { + font-size: 12px; + font-weight: 500; + color: #fff; +} + +#bluetooth-widget-top { + padding-bottom: 10px; + border-bottom: 1px solid alpha(#aaa, 0.3); +} + +#device-icon { + border-radius: 50%; + padding: 5px; + margin-bottom: 2.5px; + margin-top: 2.5px; + margin-right: 10px; + background-color: #555; +} + +#devices-title { + color: alpha(#fff, 0.6); +} + +#device-icon.paired { + background-color: #888; +} + +#device-icon.connected { + background-color: #2369ff; +} + +#toggle-button { + min-width: 40px; + min-height: 20px; + background-color: var(--surface); + border-radius: 15px; + 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), + transform 0.25s cubic-bezier(0.5, 0.25, 0, 1.25); +} + +#toggle-button:checked { + background-color: var(--primary); +} + +#toggle-button:checked slider { + background-color: var(--shadow); +} + +#toggle-button:checked image { + color: var(--shadow); +} + +.menu { + margin: 5px; + background-color: alpha(#000, 0.3); + border: 0.5px solid alpha(#888, 0.4); + box-shadow: inset 0 0 200px 0 alpha(#111, 0.3); + border-radius: 12px; + padding: 1rem; +} + +.title { + padding: 0; + font-weight: 500; + font-size: 12px; +} + +.ct { + margin-left: 5px; +} + +#volume-widget-icon { + margin-top: -24px; + margin-right: -30px; + color: var(--shadow); +} + +#brightness-widget-icon { + margin-top: -24px; + margin-right: -25px; + color: alpha(#111, 0.6); +} + +#control-center-menu slider { + background-image: none; + background-color: transparent; + padding: 2px; + min-width: 4px; + min-height: 20px; + border-radius: 20px; +} + +#control-center-menu scale { + background-color: transparent; + margin-top: 10px; + border-radius: 20px; +} + +#control-center-menu trough { + min-width: 25px; + border-radius: 99px; + background-color: alpha(#666, 0.5); + border: 1px solid alpha(#444, 0.3); +} + +#control-center-menu highlight { + background: alpha(#fff, 0.8); + border-radius: 99px; +} + +#control-center-menu mark indicator { + background: none; + background-image: none; + color: alpha(#fff, 0.2); +} + +#control-center-menu mark label { + background: none; + background-image: none; + color: alpha(#fff, 0.2); +} diff --git a/styles/dropdown.css b/styles/dropdown.css new file mode 100644 index 0000000..e0deca2 --- /dev/null +++ b/styles/dropdown.css @@ -0,0 +1,61 @@ +#dropdown-menu { + background-color: transparent; + border-radius: 0.75rem; + margin: 0; +} + +#dropdown-options { + background-color: alpha(#000, 0.3); + box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); + border: 1px solid alpha(#111, 0.4); + border-radius: 0.75rem; + padding: 0.5rem; +} + +#dropdown-option { + background-color: transparent; + border-radius: 5px; + padding: 0px 10px; + margin: 1px 2px; + transition: all 0ms ease-in-out; +} + +#dropdown-option:hover { + transition: all 20ms ease-in-out; + color: #222; + background-color: alpha(#2369ff, 1); +} + +#dropdown-option-label { + font-size: 12px; + margin: 0px; + color: #fff; + font-weight: 400; +} + +#dropdown-option-label:first-child { + padding-right: 5rem; +} + +#dropdown-option:hover #dropdown-option-keybind { + color: #fff; +} + +#dropdown-option-keybind { + font-family: "Fira Code Nerd Font"; + font-weight: 500; + color: #aaa; +} + +/* Divider */ + +#dropdown-divider-box { + background-color: transparent; + border-radius: 5px; + padding: 0px 10px; + margin: 2px; +} +#dropdown-divider { + border-bottom: 1px solid alpha(#aaa, 0.3); + margin: 2px 0; +} diff --git a/styles/menubar.css b/styles/menubar.css deleted file mode 100644 index 33a622a..0000000 --- a/styles/menubar.css +++ /dev/null @@ -1,21 +0,0 @@ -#menu-separator { - color: red; -} - -#dropdown-content { - background-color: alpha(var(--shadow), 0.6); - border-radius: 8px; -} - -#dropdown-label { - margin-left: 10px; - padding: 4px; -} - -#dropdown-item { - border-radius: 4px; -} - -#dropdown-item:hover { - background-color: var(--surface-bright); -} diff --git a/styles/panel.css b/styles/panel.css index a02d554..8bbc08b 100644 --- a/styles/panel.css +++ b/styles/panel.css @@ -1,5 +1,7 @@ #panel { - background-color: alpha(var(--shadow), 0.6); + background-color: alpha(#010101, 0.1); + border-bottom: 1px solid alpha(#010101, 0.025); + transition: all 120ms ease-in-out; } #panel-icon { @@ -8,7 +10,7 @@ #panel-button { border-radius: 8px; - margin: 0 4px 0 6px; + margin: 0 4px 0 4px; min-width: 20px; min-height: 20px; } @@ -17,6 +19,36 @@ background-color: var(--surface-bright); } +#battery-button { + padding-right: 3px; + padding-left: 3px; + border-radius: 8px; +} + +#battery-button:hover { + background-color: var(--surface-bright); +} + +#network-button { + padding-right: 3px; + padding-left: 3px; + border-radius: 8px; +} + +#network-button:hover { + background-color: var(--surface-bright); +} + +#bt-button { + padding-right: 3px; + padding-left: 3px; + border-radius: 8px; +} + +#bt-button:hover { + background-color: var(--surface-bright); +} + #tray-button { border-radius: 8px; margin: 0 4px 0 0px; @@ -29,7 +61,7 @@ } #date-time { - margin-right: 6px; + margin: 0 10px; } #modules-left, @@ -38,36 +70,25 @@ padding: 3px 0; } -#notch-spot { - background-color: var(--surface); +.button { + border-radius: 5px; + padding: 0 5px; + margin: 0 2.5px; + background: radial-gradient(alpha(#aaa, 0) 0%, transparent, transparent); + transition: all 100ms ease-in-out; } -#indicators-icon { - font-size: 20px; +.button:hover { + background: radial-gradient(alpha(#aaa, 0.2) 100%, transparent, transparent); } -#bt-icon { - font-size: 18px; -} +/* .button.active { */ +/* background: radial-gradient(alpha(#aaa, 0.2) 100%, transparent, transparent); */ +/* } */ -#indicators { - margin: 0 8px; -} +/* EnvSH Button */ -#indicators-icon.alert { - color: var(--red-dim); -} - -#menubar-button { - margin: 0 2px; - border-radius: 4px; - padding: 0 6px; -} - -#menubar-button:hover { - background-color: var(--surface-bright); -} - -#activewindow-label { - font-weight: bold; +#modus-button label { + font-size: 17px; + margin: 0 10px; } diff --git a/utils/exml.py b/utils/exml.py new file mode 100644 index 0000000..80097d0 --- /dev/null +++ b/utils/exml.py @@ -0,0 +1,90 @@ +from xml import etree +from xml.etree import ElementTree +from fabric.utils import get_relative_path +import json + + +def parse(xml): + with open(xml, "rb") as f: + tree = ElementTree.parse(f) + root = tree.getroot() + return root + + +def dxml(root, depth=0): + val = [] + for child in root: + attrstring = {} + additional = {} + if len(child.attrib) == 0: + if child.tag == "BoxV": + additional["orientation"] = "vertical" + elif child.tag == "BoxH": + additional["orientation"] = "horizontal" + for k, v in child.attrib.items(): + if v == None: + continue + if child.tag == "Ref": + continue + if child.tag == "BoxV": + additional["orientation"] = "vertical" + elif child.tag == "BoxH": + additional["orientation"] = "horizontal" + if k == "path": + v = get_relative_path("../" + v) + k = "svg_file" + if v == "true" or v == "false": + v = v == "true" + attrstring[k] = v + for k, v in additional.items(): + attrstring[k] = v + tag = child.tag + if tag in ["BoxV", "BoxH"]: + tag = "Box" + if tag == "Ref": + val.append( + { + "name": tag, + "text": child.text, + } + ) + continue + val.append( + { + "name": tag, + "attributes": attrstring, + "children": dxml(child, depth + 1), + } + ) + return val + + +def parse_child(tags, refs, child, parent): + for ch in child: + ch = ch.copy() + name = ch.get("name", None) + attrs = ch.get("attributes", None) + if attrs: + for attr in attrs: + if str(attrs[attr]).isnumeric(): + attrs[attr] = int(attrs[attr]) + children = ch.get("children", None) + if children: + ch.pop("children") + if name == "Ref": + text = ch.get("text", None) + v = refs[text] + parent.children = [*parent.children, v] + else: + v = tags[name](**attrs) + if children: + v.children = parse_child(tags, refs, children, v) + parent.children = [*parent.children, v] + return parent.children + + +def exml(file: str, tags: dict, refs: dict, root): + odi = dxml(parse(file)) + di = root(children=[]) + parse_child(tags, refs, odi, di) + return di diff --git a/widgets/dropdown.py b/widgets/dropdown.py new file mode 100644 index 0000000..f41ffc8 --- /dev/null +++ b/widgets/dropdown.py @@ -0,0 +1,92 @@ +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 + +dropdowns = [] + + +def dropdown_divider(comment): + return Box( + children=[Box(name="dropdown-divider", h_expand=True)], + name="dropdown-divider-box", + h_align="fill", + h_expand=True, + v_expand=True, + ) + + +class ModusDropdown(PopupWindow): + def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs): + super().__init__( + layer="top", + exclusivity="auto", + name="dropdown-menu", + title="modus", + keyboard_mode="none", + visible=False, + **kwargs, + ) + + self.id = dropdown_id or str(len(dropdowns)) + dropdowns.append(self) + self._mousecapture_parent = None # Will be set by mousecapture + + modus_service.connect("dropdowns-hide-changed", self.hide_dropdown) + + self.dropdown = Box( + children=dropdown_children or [], + h_expand=True, + name="dropdown-options", + orientation="vertical", + ) + + self.child_box = CenterBox(start_children=[self.dropdown]) + + self.event_box = EventBox( + events=["enter-notify-event", "leave-notify-event"], + child=self.child_box, + all_visible=True, + ) + + self.children = [self.event_box] + self.connect("button-press-event", self.hide_dropdown) + self.add_keybinding("Escape", self.hide_dropdown) + + def toggle_dropdown(self, button, parent=None): + self.set_visible(not self.is_visible()) + modus_service.current_dropdown = self.id if self.is_visible() else None + + def _init_mousecapture(self, mousecapture): + """Store reference to mousecapture parent for hiding""" + self._mousecapture_parent = mousecapture + + def hide_dropdown(self, widget, event): + if self.is_visible(): + self.hide() + if str(modus_service.current_dropdown) == str(self.id): + modus_service.current_dropdown = None + + def hide_via_mousecapture(self): + """Hide dropdown via mousecapture parent""" + if self._mousecapture_parent: + self._mousecapture_parent.hide_child_window() + + def _set_mousecapture(self, visible: bool) -> None: + self.set_visible(visible) + if visible: + modus_service.current_dropdown = self.id + else: + if str(modus_service.current_dropdown) == str(self.id): + modus_service.current_dropdown = None + + def on_cursor_enter(self, *_): + self.set_visible(True) + + def on_cursor_leave(self, *_): + if self.is_hovered(): + return + self.set_visible(False) + modus_service.dropdowns_hide = not modus_service.dropdowns_hide diff --git a/widgets/mousecapture.py b/widgets/mousecapture.py new file mode 100644 index 0000000..2f0c2a5 --- /dev/null +++ b/widgets/mousecapture.py @@ -0,0 +1,142 @@ +from typing import Any +import cairo + +from gi.repository import GtkLayerShell, GLib # 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 + + +class MouseCapture(Window): + """A background overlay that captures outside clicks without blocking child window interactions""" + + def __init__(self, layer: str, child_window: Window, **kwargs): + super().__init__( + layer="top", # Use top layer to capture events + anchor="top bottom left right", + exclusivity="auto", + title="modus", + name="MouseCapture", + keyboard_mode="none", # Don't steal keyboard + all_visible=False, + visible=False, + **kwargs, + ) + + GtkLayerShell.set_exclusive_zone(self, -1) + + self.child_window = child_window + + # Ensure child window is on overlay layer to be above this capture + if hasattr(self.child_window, "layer"): + self.child_window.layer = "overlay" + + if hasattr(self.child_window, "_init_mousecapture"): + self.child_window._init_mousecapture(self) + + # Create transparent event box that captures clicks + self.event_box = EventBox( + events=["button-press-event"], + all_visible=True, + ) + self.event_box.connect("button-press-event", self.on_overlay_click) + self.children = [self.event_box] + + # Make the overlay transparent + self.set_app_paintable(True) + self.connect("draw", self.on_draw) + + # Add escape key binding to child window + if hasattr(self.child_window, "add_keybinding"): + self.child_window.add_keybinding("Escape", self.hide_child_window) + + def on_draw(self, _widget, cr): + """Make overlay transparent""" + cr.set_source_rgba(0, 0, 0, 0) # Fully transparent + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.paint() + return False + + def on_overlay_click(self, _widget, event): + """Handle overlay clicks - check if click is outside child window""" + if not self.child_window.is_visible(): + return False + + # Get click coordinates + click_x = event.x_root + click_y = event.y_root + + # Get child window bounds + try: + child_x, child_y = self.child_window.get_position() + child_allocation = self.child_window.get_allocation() + + # Check if click is inside child window bounds + inside_child = ( + child_x <= click_x <= child_x + child_allocation.width + and child_y <= click_y <= child_y + child_allocation.height + ) + + if not inside_child: + # Click is outside child window - hide it with delay + GLib.timeout_add( + 50, lambda: self.hide_child_window(None, None) or False + ) + return True # Consume the event + + except Exception as e: + print(f"Error checking click position: {e}") + # If we can't determine position, hide child window to be safe + GLib.timeout_add(50, lambda: self.hide_child_window(None, None) or False) + return True + + # Click is inside child window - don't consume event + return False + + def show_child_window(self, widget: Widget = None, event: Any = None) -> None: + self.set_child_window_visible(True) + + def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: + self.set_child_window_visible(False) + + def set_child_window_visible(self, visible: bool) -> None: + if visible: + self.child_window.show() + self.show() + else: + self.child_window.hide() + self.hide() + + if hasattr(self.child_window, "_set_mousecapture"): + self.child_window._set_mousecapture(visible) + + def toggle_mousecapture(self, *_) -> None: + if self.is_visible(): + self.set_child_window_visible(False) + else: + self.set_child_window_visible(True) + + +class DropDownMouseCapture(MouseCapture): + """A specialized MouseCapture for dropdown menus with service integration""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + modus_service.connect("dropdowns-hide-changed", self.dropdowns_hide_changed) + + def hide_child_window(self, widget: Widget = None, event: Any = None) -> None: + """Hide child window and update dropdown service state""" + # Update service state before hiding to prevent conflicts + if hasattr(self.child_window, "id"): + if str(modus_service.current_dropdown) == str(self.child_window.id): + modus_service.current_dropdown = None + super().hide_child_window(widget, event) + + def dropdowns_hide_changed(self, widget: Widget = None, event: Any = None) -> None: + """Handle dropdown service hide changes""" + if hasattr(self.child_window, "id"): + if modus_service.current_dropdown == self.child_window.id: + return + return self.hide_child_window(widget, event) diff --git a/widgets/popup_window.py b/widgets/popup_window.py new file mode 100644 index 0000000..cff0e2f --- /dev/null +++ b/widgets/popup_window.py @@ -0,0 +1,145 @@ +import gi # type: ignore +from gi.repository import Gdk, Gtk, GtkLayerShell # type: ignore + +from widgets.wayland import WaylandWindow + +gi.require_version("GtkLayerShell", "0.1") + + +class PopupWindow(WaylandWindow): + def __init__( + self, + parent: WaylandWindow, + pointing_to: Gtk.Widget | None = None, + margin: tuple[int, ...] | str = "0px 0px 0px 0px", + **kwargs, + ): + super().__init__(**kwargs) + self.exclusivity = "none" + + self._parent = parent + self._pointing_widget = pointing_to + self._base_margin = self.extract_margin(margin) + self.margin = self._base_margin.values() + + self.connect("notify::visible", self.do_update_handlers) + + def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]: + if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()): # type: ignore + return 0, 0 + allocation = widget.get_allocation() + x, y = widget.translate_coordinates(toplevel, allocation.x, allocation.y) or ( + 0, + 0, + ) + return round(x / 2), round(y / 2) + + def set_pointing_to(self, widget: Gtk.Widget | None): + if self._pointing_widget: + try: + self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) + except Exception: + pass + self._pointing_widget = widget + return self.do_update_handlers() + + def do_update_handlers(self, *_): + if not self._pointing_widget: + return + + if not self.get_visible(): + try: + self._pointing_widget.disconnect_by_func(self.do_handle_size_allocate) + self.disconnect_by_func(self.do_handle_size_allocate) + except Exception: + pass + return + + self._pointing_widget.connect("size-allocate", self.do_handle_size_allocate) + self.connect("size-allocate", self.do_handle_size_allocate) + + return self.do_handle_size_allocate() + + def do_handle_size_allocate(self, *_): + return self.do_reposition(self.do_calculate_edges()) + + def do_calculate_edges(self): + move_axe = "x" + parent_anchor = self._parent.anchor + + if len(parent_anchor) != 3: + return move_axe + + if ( + GtkLayerShell.Edge.LEFT in parent_anchor + and GtkLayerShell.Edge.RIGHT in parent_anchor + ): + move_axe = "x" + if GtkLayerShell.Edge.TOP in parent_anchor: + self.anchor = "left top" + else: + self.anchor = "left bottom" + elif ( + GtkLayerShell.Edge.TOP in parent_anchor + and GtkLayerShell.Edge.BOTTOM in parent_anchor + ): + move_axe = "y" + if GtkLayerShell.Edge.RIGHT in parent_anchor: + self.anchor = "top right" + else: + self.anchor = "top left" + + return move_axe + + def do_reposition(self, move_axe: str): + parent_margin = self._parent.margin + parent_x_margin, parent_y_margin = parent_margin[0], parent_margin[3] + + height = self.get_allocated_height() + width = self.get_allocated_width() + + screen = Gdk.Screen.get_default() + screen_width = screen.get_width() + + if self._pointing_widget: + coords = self.get_coords_for_widget(self._pointing_widget) + if coords[0] + width > screen_width: + coords_centered = ( + round( + coords[0] + - (width - self._pointing_widget.get_allocated_width()) + ), + round(coords[1] + self._pointing_widget.get_allocated_height() / 2), + ) + else: + coords_centered = ( + round(coords[0]), + round(coords[1] + self._pointing_widget.get_allocated_height() / 2), + ) + else: + coords_centered = ( + round(self._parent.get_allocated_width() / 2), + round(self._parent.get_allocated_height() / 2), + ) + + self.margin = tuple( + a + b + for a, b in zip( + ( + ( + 0, + 0, + 0, + round((parent_x_margin + coords_centered[0])), + ) + if move_axe == "x" + else ( + round((parent_y_margin + coords_centered[1]) - (height / 2)), + 0, + 0, + 0, + ) + ), + self._base_margin.values(), + ) + ) diff --git a/utils/wayland.py b/widgets/wayland.py similarity index 100% rename from utils/wayland.py rename to widgets/wayland.py