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.brightness_scale]
+
+
+
+
+ [self.volume_scale]
+
+
+
+ [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