Huge updates: ControlCenter, DropDown Menu

This commit is contained in:
sankalp committed 2025-07-31 20:46:02 +05:45
1 parent 1f2d0a33f4
commit 9d83d960ef
34 files changed
+3261 -1440

No files matched your search

@@ -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
+257 -252
View File
@@ -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)
-93
View File
@@ -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}")
-63
View File
@@ -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)
+347 -339
View File
@@ -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()
@@ -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)