✨ feat: implement core functionality and UI for Modus shell

This commit is contained in:
sankalp committed 2025-07-30 09:28:32 +05:45
1 parent 5789013e2e
commit acec6bdda7
129 files changed
+33424 -226

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
from fabric.bluetooth import BluetoothClient
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.utils import get_relative_path
from fabric.widgets.svg import Svg
import utils.icons as icons
from services.network import NetworkClient
from gi.repository import GLib
from services.battery import Battery as BatteryService
class WifiIndicator(Button):
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)
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.bluetooth.connect("device-added", self.on_device_added)
self.bluetooth.connect("device-removed", self.on_device_removed)
self.update_state()
def update_state(self):
if not self.bluetooth.enabled:
self.bt_icon.set_from_file(get_relative_path("../../../config/assets/icons/bluetooth-off.svg"))
tooltip = "Bluetooth disabled"
else:
connected_devices = self.bluetooth.connected_devices
if connected_devices:
self.bt_icon.set_from_file(get_relative_path("../../../config/assets/icons/bluetooth.svg"))
if len(connected_devices) == 1:
device = connected_devices[0]
tooltip = f"Connected to {device.alias}"
if device.battery_percentage > 0:
tooltip += f" ({device.battery_percentage:.0f}%)"
else:
tooltip = f"Connected to {len(connected_devices)} devices"
else:
self.bt_icon.set_from_file(get_relative_path("../../../config/assets/icons/bluetooth.svg"))
tooltip = "No devices connected"
self.set_tooltip_text(tooltip)
def on_bluetooth_changed(self, *args):
self.update_state()
def on_device_added(self, _, address):
self.update_state()
def on_device_removed(self, _, address):
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"
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
else:
profile_icon = icons.power_balanced
tooltip_points.append(f"• Profile: {profile_icon} {power_profile}")
temp = self._battery.temperature
if temp != "N/A":
tooltip_points.append(f"• Temperature: {temp}")
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}")
elif state == "DISCHARGING":
time_to_empty = self._battery.time_to_empty
if time_to_empty:
tooltip_points.append(f"• Time remaining: {time_to_empty}")
return "\n".join(tooltip_points)
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")
else:
self.icon.remove_style_class("alert")
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}"))
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,
)
self.show_all()
+95
View File
@@ -0,0 +1,95 @@
"""
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}")
+75
View File
@@ -0,0 +1,75 @@
"""
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)
+362
View File
@@ -0,0 +1,362 @@
from fabric.widgets.box import Box
from fabric.widgets.button import Button
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
class DropdownWindow(WaylandWindow):
def __init__(self, x=0, y=0, parent_menubar=None, **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,
**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,
)
self.add(self.content_box)
self.connect("enter-notify-event", self.on_mouse_enter)
self.connect("leave-notify-event", self.on_mouse_leave)
self.auto_close_timer = None
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
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
def auto_close_callback(self):
if self.parent_menubar:
self.parent_menubar.hide_dropdown()
self.auto_close_timer = None
return False
class MenuBar(Box):
def __init__(self, **kwargs):
super().__init__(
name="menubar",
orientation="h",
spacing=0,
**kwargs,
)
self.active_window = HyprlandActiveWindow(name="hyprland-window")
self.active_window.connect("window-activated", self.on_window_changed)
self.current_window_class = None
self.current_window_title = None
self.current_dropdown = None
self.dropdown_window = None
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 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