feat(modules): restructure battery UI and add dynamic layout rebuilding

New Features
- Introduce `get_battery_icon_file` to generate battery icons based on percentage and charging state.
- Add fallback Bluetooth control using `rfkill` when Fabric Bluetooth fails.

Enhancements
- Refactor layout management with `_rebuild_layout_from_config` and dynamic indicator instantiation.
- Replace large `EnergyModeButton` class with lightweight utility functions.
- Simplify notification icon updates using `dynamic_file`.

Bug Fixes
- No explicit bug fixes; changes focus on feature additions and refactoring.
This commit is contained in:
sankalp committed 2026-02-26 06:24:44 +05:45
1 parent 15be2f3de6
commit e97311e2b0
18 files changed
+2373 -2233

No files matched your search

+31 -138
View File
@@ -1,21 +1,17 @@
"""
Enhanced System Tray Icon Handling
Simplified System Tray Icon Patch
This module provides enhanced icon loading capabilities for system tray items,
including fallback mechanisms for file paths and common icon locations.
Focuses on loading icons from absolute file paths and fixing tooltip naming.
"""
import os
from gi.repository import GdkPixbuf, Gtk
from pathlib import Path
from fabric.system_tray.widgets import SystemTrayItem
# FIX: the tooltip should show application names instead of unknown
from fabric.utils import GdkPixbuf, GLib
def patched_do_update_properties(self, *_):
# Try default GTK theme first
# Determine the icon name to use
icon_name = self._item.icon_name
attention_icon_name = self._item.attention_icon_name
@@ -24,142 +20,39 @@ def patched_do_update_properties(self, *_):
else:
preferred_icon_name = icon_name
# Try to load from default GTK theme
if preferred_icon_name:
try:
default_theme = Gtk.IconTheme.get_default()
if default_theme.has_icon(preferred_icon_name):
pixbuf = default_theme.load_icon(
preferred_icon_name, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE
# 1. Try the standard fabric/GTK implementation first
pixbuf = self._item.get_preferred_icon_pixbuf(self._icon_size)
# 2. If standard lookup fails, try your specific absolute path logic
if pixbuf is None and preferred_icon_name:
path = Path(preferred_icon_name)
if path.is_absolute() and path.exists():
try:
target_size = self._icon_size if self._icon_size else 24
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path),
target_size,
target_size,
True, # Preserve aspect ratio
)
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
# Set tooltip
tooltip = self._item.tooltip
self.set_tooltip_markup(
tooltip.description or tooltip.title or self._item.title.title()
if self._item.title
else "Unknown"
)
return
except:
pass
except GLib.GError:
pixbuf = None
# Enhanced fallback handling for file paths
if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name):
return
# Apply the resulting pixbuf or the fallback "missing" icon
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
else:
self._image.set_from_icon_name("image-missing", self._icon_size)
# Fallback to original implementation
original_do_update_properties(self, *_)
def _try_load_icon_from_path(self, icon_path):
try:
# Check if it's a file path and handle it directly
if os.path.isabs(icon_path) or "/" in icon_path:
# Try to load as SVG from the original path if it exists
if os.path.exists(icon_path):
if icon_path.lower().endswith(".svg"):
# Load SVG directly
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
icon_path, self._icon_size, self._icon_size
)
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
self._set_tooltip()
return True
else:
# Load other image formats
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
icon_path, self._icon_size, self._icon_size
)
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
self._set_tooltip()
return True
# If it's a file path, try to extract just the filename for theme lookup
filename = os.path.basename(icon_path)
if filename:
# Remove extension for theme lookup
name_without_ext = os.path.splitext(filename)[0]
default_theme = Gtk.IconTheme.get_default()
# Try filename without extension
if default_theme.has_icon(name_without_ext):
pixbuf = default_theme.load_icon(
name_without_ext,
self._icon_size,
Gtk.IconLookupFlags.FORCE_SIZE,
)
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
self._set_tooltip()
return True
# Try full filename
if default_theme.has_icon(filename):
pixbuf = default_theme.load_icon(
filename, self._icon_size, Gtk.IconLookupFlags.FORCE_SIZE
)
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
self._set_tooltip()
return True
# If it looks like a file path but doesn't exist, try common icon locations
if os.path.isabs(icon_path):
common_icon_dirs = [
"/usr/share/icons",
"/usr/share/pixmaps",
"/usr/local/share/icons",
"/usr/local/share/pixmaps",
os.path.expanduser("~/.local/share/icons"),
os.path.expanduser("~/.icons"),
]
filename = os.path.basename(icon_path)
for icon_dir in common_icon_dirs:
potential_path = os.path.join(icon_dir, filename)
if os.path.exists(potential_path):
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
potential_path, self._icon_size, self._icon_size
)
if pixbuf:
self._image.set_from_pixbuf(pixbuf)
self._set_tooltip()
return True
except:
continue
except Exception:
pass
return False
def _set_tooltip(self):
tooltip = self._item.tooltip
self.set_tooltip_markup(
tooltip.description or tooltip.title or self._item.title.title()
if self._item.title
else "Unknown"
tooltip.description
or tooltip.title
or (self._item.title.title() if self._item.title else None)
or "Unknown"
)
def apply_enhanced_system_tray():
# Store original method
global original_do_update_properties
original_do_update_properties = SystemTrayItem.do_update_properties
# Attach helper methods to SystemTrayItem class
SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path
SystemTrayItem._set_tooltip = _set_tooltip
# Replace the do_update_properties method
# Replace the method on the class so all tray items use this logic
SystemTrayItem.do_update_properties = patched_do_update_properties
# Store reference to original method
original_do_update_properties = None
+338
View File
@@ -0,0 +1,338 @@
from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow
from fabric.utils import FormattedString, exec_shell_command_async
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 utils.app_name_resolver import format_window
from utils.roam import modus_service
from utils.utils import setup_cursor_hover
from widgets.about import About, AboutApp
from widgets.dropdown import ModusDropdown, dropdown_divider, dropdowns
from widgets.mousecapture import DropDownMouseCapture
def has_active_window():
return (
modus_service.current_active_app_name
and modus_service.current_active_app_name != "Finder"
)
def create_menu_button(label, on_clicked=None):
button_kwargs = {
"label": label,
"name": "global-menu",
}
if on_clicked is not None:
button_kwargs["on_clicked"] = on_clicked
button = Button(**button_kwargs)
setup_cursor_hover(button, "pointer")
return button
def create_dropdown_with_capture(
dropdown_id, parent, dropdown_children, layer="bottom"
):
dropdown = ModusDropdown(
dropdown_id=dropdown_id,
parent=parent,
dropdown_children=dropdown_children,
)
mouse_capture = DropDownMouseCapture(layer=layer, child_window=dropdown)
return mouse_capture
def manage_button_style_classes(buttons, active_button=None, style_class="active"):
for button in buttons:
if button: # Check if button exists (some might be None)
button.remove_style_class(style_class)
if active_button:
active_button.add_style_class(style_class)
def show_about_app(_=None):
if not has_active_window():
return
app_name = modus_service.current_active_app_name
about_window = AboutApp(app_name=app_name, wmclass="")
about_window.toggle(None)
def on_click_subthread(button, on_clicked, on_click):
if on_clicked:
on_clicked(button)
else:
exec_shell_command_async(on_click, lambda *_: None)
for dropdown in dropdowns:
if dropdown.is_visible():
dropdown.hide_via_mousecapture()
break
def dropdown_option(
label: str = "",
keybind: str = "",
on_click='echo "ModusPanelDropdown Action"',
on_clicked=None,
):
btn = 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=lambda button: on_click_subthread(button, on_clicked, on_click),
h_expand=True,
v_expand=True,
)
setup_cursor_hover(btn, "pointer")
return btn
class SystemDropdown(ModusDropdown):
def __init__(self, parent, **kwargs):
super().__init__(
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,
)
class GlobalMenuDropdowns:
def __init__(self, parent):
self.parent = parent
self.system_dropdown = SystemDropdown(parent=parent)
self.menu_button_dropdown = DropDownMouseCapture(
layer="bottom", child_window=self.system_dropdown
)
self.menu_button = Button(
label="Modus",
name="global-menu",
on_clicked=lambda _: self.menu_button_dropdown.toggle_mousecapture(),
)
self.menu_button_dropdown.child_window.set_pointing_to(self.menu_button)
self.global_title_menu_about = dropdown_option(
f"About {modus_service.current_active_app_name}",
on_clicked=show_about_app,
)
self.global_menu_title = create_dropdown_with_capture(
"global-menu-title",
parent,
[self.global_title_menu_about],
)
self.global_menu_view = create_dropdown_with_capture(
"global-menu-view",
parent,
[
dropdown_option(
"Enter Full Screen",
on_click="hyprctl dispatch fullscreen",
),
],
)
self.global_menu_window = create_dropdown_with_capture(
"global-menu-window",
parent,
[
dropdown_option(
"Zoom In",
"󰍉 +",
on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')",
),
dropdown_option(
"Zoom Out",
"󰍉 -",
on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')",
),
dropdown_divider("---------------------"),
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 _: exec_shell_command_async(
"bash ~/.config/scripts/winpin.sh", lambda *_: None
),
),
],
)
self.global_menu_help = create_dropdown_with_capture(
"global-menu-help",
parent,
[
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/"
),
],
)
modus_service.connect(
"current-active-app-name-changed", self._on_active_app_changed
)
self.global_menu_button_title = Button(
child=ActiveWindow(
formatter=FormattedString(
"{ format_window(win_title, win_class) }",
format_window=format_window,
)
),
name="global-menu",
on_clicked=self._on_title_button_clicked,
)
self.global_menu_title.child_window.set_pointing_to(
self.global_menu_button_title
)
# File, Edit and Go buttons are placeholders - no dropdowns implemented yet
self.global_menu_button_file = create_menu_button("File")
self.global_menu_button_edit = create_menu_button("Edit")
self.global_menu_button_go = create_menu_button("Go")
self.global_menu_button_view = create_menu_button(
"View",
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_window = create_menu_button(
"Window",
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 = create_menu_button(
"Help",
lambda _: self.global_menu_help.toggle_mousecapture(),
)
self.global_menu_help.child_window.set_pointing_to(self.global_menu_button_help)
self.all_menu_buttons = [
self.menu_button,
self.global_menu_button_title,
self.global_menu_button_file,
self.global_menu_button_edit,
self.global_menu_button_view,
self.global_menu_button_go,
self.global_menu_button_window,
self.global_menu_button_help,
]
self.dropdown_button_map = {
"os-menu": self.menu_button,
"global-menu-title": self.global_menu_button_title,
"global-menu-file": self.global_menu_button_file,
"global-menu-edit": self.global_menu_button_edit,
"global-menu-view": self.global_menu_button_view,
"global-menu-go": self.global_menu_button_go,
"global-menu-window": self.global_menu_button_window,
"global-menu-help": self.global_menu_button_help,
}
modus_service.connect("current-dropdown-changed", self.changed_dropdown)
modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns)
def _on_title_button_clicked(self, _):
if has_active_window():
self.global_menu_title.toggle_mousecapture()
def _on_active_app_changed(self, _, value):
self.global_title_menu_about.set_property("label", f"About {value}")
def hide_dropdowns(self, *_):
manage_button_style_classes(self.all_menu_buttons)
def changed_dropdown(self, _, dropdown_id):
active_button = self.dropdown_button_map.get(dropdown_id)
manage_button_style_classes(self.all_menu_buttons, active_button)
class GlobalMenu(Box):
def __init__(self, parent_window=None, **kwargs):
if parent_window is None:
parent_window = kwargs.pop("parent_window", None)
super().__init__(
name="globalmenu", orientation="horizontal", spacing=0, **kwargs
)
self.dropdown_system = GlobalMenuDropdowns(parent=parent_window)
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,
]
def show_system_dropdown(self, imac_button):
self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to(
imac_button
)
mouse_capture = self.dropdown_system.menu_button_dropdown
mouse_capture.set_child_window_visible(not mouse_capture.is_visible())
+145 -169
View File
@@ -1,19 +1,59 @@
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.label import Label
from fabric.widgets.svg import Svg
from fabric.widgets.wayland import WaylandWindow as Window
from utils.utils import setup_cursor_hover, svg_file
from widgets.battery_widget import BatteryControl
from modules.controlcenter.battery import BatteryControl
from modules.controlcenter.bluetooth import BluetoothConnections
from modules.controlcenter.wifi import WifiConnections
from services.battery import Battery
from services.battery import BatteryService, DeviceState
from services.network import NetworkClient
from utils.functions import format_duration, get_wifi_icon_for_strength
from utils.roam import modus_service
from utils.functions import get_wifi_icon_for_strength, get_wifi_connecting_icon
from widgets.mousecapture import DropDownMouseCapture
from widgets.wayland import WaylandWindow as Window
def create_control_window(name_prefix):
return Window(
layer="overlay",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name=f"{name_prefix}-window",
visible=False,
)
def create_mouse_capture(window):
return DropDownMouseCapture(layer="top", child_window=window)
def setup_control_center(
show_window, name_prefix, widget_class, parent, **widget_kwargs
):
if not show_window:
return None, None, None
window = create_control_window(name_prefix)
widget = widget_class(parent, **widget_kwargs)
window.children = [widget]
mouse_capture = create_mouse_capture(window)
return window, widget, mouse_capture
def handle_indicator_click(mouse_capture):
if mouse_capture:
mouse_capture.toggle_mousecapture()
def hide_control_center(mouse_capture):
if mouse_capture:
mouse_capture.hide_child_window()
class BluetoothIndicator(Box):
@@ -22,13 +62,7 @@ class BluetoothIndicator(Box):
self.show_window = show_window
self.bluetooth = BluetoothClient()
self.bt_icon = Svg(
name="bt-icon",
size=22,
svg_file=get_relative_path(
"../../../config/assets/icons/applets/bluetooth-clear.svg"
),
)
self.bt_icon = svg_file("applets/bluetooth-clear.svg", size=22)
self.bt_button = Button(
name="bt-button", child=self.bt_icon, on_clicked=self.on_bluetooth_clicked
@@ -36,30 +70,21 @@ class BluetoothIndicator(Box):
self.add(self.bt_button)
# Create Bluetooth control center widget only if show_window is True
if self.show_window:
self.bluetooth_window = Window(
layer="overlay",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="bluetooth-control-window",
visible=False,
)
# Set pointer cursor on hover
setup_cursor_hover(self.bt_button, "pointer")
self.bluetooth_widget = BluetoothConnections(self, show_back_button=False)
self.bluetooth_window.children = [self.bluetooth_widget]
# Create mouse capture for Bluetooth widget
self.bluetooth_mousecapture = DropDownMouseCapture(
layer="top", child_window=self.bluetooth_window
)
else:
self.bluetooth_window = None
self.bluetooth_widget = None
self.bluetooth_mousecapture = None
# Setup control center using shared function
(
self.bluetooth_window,
self.bluetooth_widget,
self.bluetooth_mousecapture,
) = setup_control_center(
self.show_window,
"bluetooth",
BluetoothConnections,
self,
show_back_button=False,
)
modus_service.connect("bluetooth-changed", self.on_bluetooth_changed)
self.bluetooth.connect("changed", self.on_bluetooth_direct_changed)
@@ -71,26 +96,14 @@ class BluetoothIndicator(Box):
def update_state(self):
if not self.bluetooth.enabled:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-off-clear.svg"
)
)
self.bt_icon.dynamic_file("applets/bluetooth-off-clear.svg")
tooltip = "Bluetooth disabled"
else:
connected_devices = self.bluetooth.connected_devices
if connected_devices:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-clear.svg"
)
)
self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
if len(connected_devices) >= 1:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-paired.svg"
)
)
self.bt_icon.dynamic_file("applets/bluetooth-paired.svg")
device = connected_devices[0]
tooltip = f"Connected to {device.alias}"
if device.battery_percentage > 0:
@@ -98,11 +111,7 @@ class BluetoothIndicator(Box):
else:
tooltip = f"Connected to {len(connected_devices)} devices"
else:
self.bt_icon.set_from_file(
get_relative_path(
"../../../config/assets/icons/applets/bluetooth-clear.svg"
)
)
self.bt_icon.dynamic_file("applets/bluetooth-clear.svg")
tooltip = "No devices connected"
self.bt_button.set_tooltip_text(tooltip)
@@ -144,19 +153,13 @@ class BluetoothIndicator(Box):
modus_service.bluetooth = bluetooth_state
def on_bluetooth_clicked(self, *args):
"""Handle Bluetooth indicator click"""
if self.show_window and self.bluetooth_mousecapture:
self.bluetooth_mousecapture.toggle_mousecapture()
handle_indicator_click(self.bluetooth_mousecapture)
def close_bluetooth(self, *args):
"""Close Bluetooth control center"""
if self.show_window and self.bluetooth_mousecapture:
self.bluetooth_mousecapture.hide_child_window()
hide_control_center(self.bluetooth_mousecapture)
def hide_controlcenter(self, *args):
"""Hide Bluetooth control center"""
if self.show_window and self.bluetooth_mousecapture:
self.bluetooth_mousecapture.hide_child_window()
hide_control_center(self.bluetooth_mousecapture)
class NetworkIndicator(Box):
@@ -166,13 +169,7 @@ class NetworkIndicator(Box):
self.network_service = NetworkClient()
self.network_icon = Svg(
name="network-icon",
size=22,
svg_file=get_relative_path(
"../../../config/assets/icons/applets/wifi-clear.svg"
),
)
self.network_icon = svg_file("applets/wifi-clear.svg", size=22)
self.network_button = Button(
name="network-button",
@@ -182,30 +179,21 @@ class NetworkIndicator(Box):
self.add(self.network_button)
# Create WiFi control center widget only if show_window is True
if self.show_window:
self.wifi_window = Window(
layer="overlay",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="wifi-control-window",
visible=False,
)
# Set pointer cursor on hover
setup_cursor_hover(self.network_button, "pointer")
self.wifi_widget = WifiConnections(self, show_back_button=False)
self.wifi_window.children = [self.wifi_widget]
# Create mouse capture for WiFi widget
self.wifi_mousecapture = DropDownMouseCapture(
layer="top", child_window=self.wifi_window
)
else:
self.wifi_window = None
self.wifi_widget = None
self.wifi_mousecapture = None
# Setup control center using shared function
(
self.wifi_window,
self.wifi_widget,
self.wifi_mousecapture,
) = setup_control_center(
self.show_window,
"wifi",
WifiConnections,
self,
show_back_button=False,
)
modus_service.connect("wlan-changed", self.on_wlan_changed)
self.network_service.connect("wifi-device-added", self.on_wifi_device_added)
@@ -230,7 +218,6 @@ class NetworkIndicator(Box):
self.update_state()
def on_ethernet_device_added(self, *args):
"""Called when Ethernet device is added"""
if self.network_service.ethernet_device:
self.network_service.ethernet_device.connect(
"changed", self.on_network_direct_changed
@@ -249,7 +236,6 @@ class NetworkIndicator(Box):
def update_modus_service_wlan_state(self):
wlan_state = "disconnected"
# Check WiFi first (prioritize WiFi over Ethernet)
if self.network_service.wifi_device:
wifi = self.network_service.wifi_device
if not wifi.wireless_enabled:
@@ -315,25 +301,17 @@ class NetworkIndicator(Box):
icon_file = "network-wired-offline.svg"
tooltip = "Ethernet disconnected"
self.network_icon.set_from_file(
get_relative_path(f"../../../config/assets/icons/applets/{icon_file}")
)
self.network_icon.dynamic_file(f"applets/{icon_file}")
self.network_button.set_tooltip_text(tooltip)
def on_wifi_clicked(self, *args):
"""Handle WiFi indicator click"""
if self.show_window and self.wifi_mousecapture:
self.wifi_mousecapture.toggle_mousecapture()
handle_indicator_click(self.wifi_mousecapture)
def close_wifi(self, *args):
"""Close WiFi control center"""
if self.show_window and self.wifi_mousecapture:
self.wifi_mousecapture.hide_child_window()
hide_control_center(self.wifi_mousecapture)
def hide_controlcenter(self, *args):
"""Hide WiFi control center"""
if self.show_window and self.wifi_mousecapture:
self.wifi_mousecapture.hide_child_window()
hide_control_center(self.wifi_mousecapture)
class BatteryIndicator(Box):
@@ -341,15 +319,9 @@ class BatteryIndicator(Box):
super().__init__(name="battery-indicator", orientation="h", **kwargs)
self.show_window = show_window
self.battery_service = Battery()
self.battery_service = BatteryService()
self.battery_icon = Svg(
name="battery-icon",
size=23,
svg_file=get_relative_path(
"../../../config/assets/icons/battery/battery-100.svg"
),
)
self.battery_icon = svg_file("battery/battery-100.svg", size=23)
self.battery_button = Button(
name="battery-button",
@@ -362,30 +334,20 @@ class BatteryIndicator(Box):
self.add(self.battery_label)
self.add(self.battery_button)
# Create Battery control center widget only if show_window is True
if self.show_window:
self.battery_window = Window(
layer="top",
title="modus",
anchor="top right",
margin="2px 10px 0px 0px",
exclusivity="auto",
keyboard_mode="on-demand",
name="battery-control-window",
visible=False,
)
# Set pointer cursor on hover
setup_cursor_hover(self.battery_button, "pointer")
self.battery_widget = BatteryControl(self, show_back_button=False)
self.battery_window.children = [self.battery_widget]
# Create mouse capture for Battery widget
self.battery_mousecapture = DropDownMouseCapture(
layer="top", child_window=self.battery_window
)
else:
self.battery_window = None
self.battery_widget = None
self.battery_mousecapture = None
(
self.battery_window,
self.battery_widget,
self.battery_mousecapture,
) = setup_control_center(
self.show_window,
"battery",
BatteryControl,
self,
show_back_button=False,
)
modus_service.connect("battery-changed", self.on_battery_changed)
self.battery_service.connect("changed", self.on_battery_direct_changed)
@@ -400,21 +362,46 @@ class BatteryIndicator(Box):
self.update_modus_service_battery_state()
self.update_state()
def _get_percentage(self):
return int(self.battery_service.get_property("Percentage") or 0)
def _get_state(self):
state_value = self.battery_service.get_property("State")
return DeviceState.get(state_value, "UNKNOWN")
def _is_present(self):
return bool(self.battery_service.get_property("IsPresent"))
def _get_time_to_empty(self):
return int(self.battery_service.get_property("TimeToEmpty") or 0)
def _get_time_to_full(self):
return int(self.battery_service.get_property("TimeToFull") or 0)
def _format_time(self, seconds: int) -> str:
return format_duration(seconds)
def _get_battery_icon_file(self, percentage: int, is_charging: bool) -> str:
clamped = max(0, min(100, percentage))
step = (clamped // 10) * 10
filename = f"battery-{step:03d}{'-charging' if is_charging else ''}.svg"
return f"battery/{filename}"
def update_modus_service_battery_state(self):
if not self.battery_service.is_present:
if not self._is_present():
battery_state = "not_present"
else:
percentage = self.battery_service.percentage
state = self.battery_service.state.lower()
percentage = self._get_percentage()
state_str = self._get_state().lower()
battery_state = f"{state}:{percentage}%"
battery_state = f"{state_str}:{percentage}%"
if state == "discharging":
time_to_empty = self.battery_service.time_to_empty
if state_str == "discharging":
time_to_empty = self._format_time(self._get_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
elif state_str == "charging":
time_to_full = self._format_time(self._get_time_to_full())
if time_to_full != "N/A":
battery_state += f":{time_to_full}"
@@ -425,11 +412,11 @@ class BatteryIndicator(Box):
if state == "CHARGING":
tooltip += " (Charging)"
time_to_full = self.battery_service.time_to_full
time_to_full = self._format_time(self._get_time_to_full())
if time_to_full != "N/A":
tooltip += f" - {time_to_full} until full"
elif state == "DISCHARGING":
time_to_empty = self.battery_service.time_to_empty
time_to_empty = self._format_time(self._get_time_to_empty())
if time_to_empty != "N/A":
tooltip += f" - {time_to_empty} remaining"
elif state == "FULLY_CHARGED":
@@ -438,40 +425,29 @@ class BatteryIndicator(Box):
return tooltip
def update_state(self):
if not self.battery_service.is_present:
# Hide the entire battery component when no battery is present
if not self._is_present():
self.set_visible(False)
return
else:
# Show the battery component when battery is present
self.set_visible(True)
percentage = self.battery_service.percentage
state = self.battery_service.state
percentage = self._get_percentage()
state = self._get_state()
is_charging = state in ["CHARGING", "FULLY_CHARGED"]
icon_file = Battery.get_battery_icon_file(
percentage, is_charging, base_path="../../../config/assets/icons/"
)
icon_file = self._get_battery_icon_file(percentage, is_charging)
tooltip = self.get_battery_tooltip(percentage, state)
percentage_text = f"{percentage}%"
# Update icon, tooltip, and percentage label
self.battery_icon.set_from_file(get_relative_path(icon_file))
self.battery_icon.dynamic_file(icon_file)
self.battery_button.set_tooltip_text(tooltip)
self.battery_label.set_label(percentage_text)
def on_battery_clicked(self, *args):
"""Handle Battery indicator click"""
if self.show_window and self.battery_mousecapture:
self.battery_mousecapture.toggle_mousecapture()
handle_indicator_click(self.battery_mousecapture)
def close_battery(self, *args):
"""Close Battery control center"""
if self.show_window and self.battery_mousecapture:
self.battery_mousecapture.hide_child_window()
hide_control_center(self.battery_mousecapture)
def hide_controlcenter(self, *args):
"""Hide Battery control center"""
if self.show_window and self.battery_mousecapture:
self.battery_mousecapture.hide_child_window()
hide_control_center(self.battery_mousecapture)
-411
View File
@@ -1,411 +0,0 @@
import json
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 modules.about import About, AboutApp
from utils.roam import modus_service
from widgets.dropdown import ModusDropdown, dropdown_divider
from widgets.mousecapture import DropDownMouseCapture
from utils.app_name_resolver import format_window
def show_about_app():
"""Show about dialog for current active application"""
try:
# Use modus_service's Hyprland connection to get current window info
wmclass = ""
title = ""
if (
hasattr(modus_service, "_hyprland_connection")
and modus_service._hyprland_connection
):
window_data = modus_service._hyprland_connection.send_command(
"j/activewindow"
).reply
if window_data:
window_info = json.loads(window_data.decode("utf-8"))
wmclass = window_info.get("class", "")
title = window_info.get("title", "")
# Don't show about dialog if there's no active window (Finder state)
if not wmclass and not title:
return
app_name = modus_service.current_active_app_name or "Finder"
# Don't show about dialog for Finder
if app_name == "Finder":
return
about_window = AboutApp(app_name=app_name, wmclass=wmclass)
about_window.toggle(None)
except Exception:
# Fallback: only show if we have a real app name
app_name = modus_service.current_active_app_name or ""
if app_name and app_name != "Finder":
about_window = AboutApp(app_name=app_name, wmclass="")
about_window.toggle(None)
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__(
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,
)
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.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)
# Global menu dropdowns
self.global_title_menu_about = dropdown_option(
f"About {modus_service.current_active_app_name}",
on_clicked=lambda _: show_about_app(),
)
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.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 In",
"󰍉 +",
on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '.float * 1.1')",
),
dropdown_option(
"Zoom Out",
"󰍉 -",
on_click="hyprctl -q keyword cursor:zoom_factor $(hyprctl getoption cursor:zoom_factor -j | jq '(.float * 0.9) | if . < 1 then 1 else . end')",
),
dropdown_divider("---------------------"),
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
),
),
],
),
)
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/"
),
],
),
)
# Create menu buttons
modus_service.connect(
"current-active-app-name-changed",
lambda _, value: self.global_title_menu_about.set_property(
"label", f"About {value}"
),
)
# Connect to active app name changes to update the title button
modus_service.connect("current-active-app-name-changed", self._on_active_app_changed)
self.global_menu_button_title = Button(
child=ActiveWindow(
formatter=FormattedString(
"{ format_window(win_title, win_class) }",
format_window=format_window,
)
),
name="global-title-button",
style_classes="button",
on_clicked=self._on_title_button_clicked,
)
self.global_menu_title.child_window.set_pointing_to(
self.global_menu_button_title
)
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 _on_title_button_clicked(self, _):
"""Handle title button click - only show dropdown if there's an active window"""
try:
# Use modus_service's Hyprland connection to get current window info
if (
hasattr(modus_service, "_hyprland_connection")
and modus_service._hyprland_connection
):
window_data = modus_service._hyprland_connection.send_command(
"j/activewindow"
).reply
if window_data:
window_info = json.loads(window_data.decode("utf-8"))
wmclass = window_info.get("class", "")
title = window_info.get("title", "")
# Only show dropdown if there's an active window (not Finder)
if wmclass or title:
self.global_menu_title.toggle_mousecapture()
return
# Fallback: check if current_active_app_name is not "Finder"
if (
modus_service.current_active_app_name
and modus_service.current_active_app_name != "Finder"
):
self.global_menu_title.toggle_mousecapture()
except Exception:
# If we can't get window info, don't show dropdown
pass
def _on_active_app_changed(self, _, value):
"""Handle active app name changes"""
# Update the "About" menu item label
self.global_title_menu_about.set_property("label", f"About {value}")
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):
"""Main MenuBar widget that contains all menu buttons"""
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)
super().__init__(name="menubar", orientation="horizontal", spacing=0, **kwargs)
# Create the dropdown system
self.dropdown_system = MenuBarDropdowns(parent=parent_window)
# 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,
]
def show_system_dropdown(self, imac_button):
self.dropdown_system.menu_button_dropdown.child_window.set_pointing_to(
imac_button
)
mouse_capture = self.dropdown_system.menu_button_dropdown
if mouse_capture.is_visible():
mouse_capture.set_child_window_visible(False)
else:
mouse_capture.set_child_window_visible(True)
+28 -35
View File
@@ -1,13 +1,12 @@
import os
import subprocess
import time
from fabric.utils import get_relative_path
from fabric.utils import GLib, get_relative_path, os
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.svg import Svg
from gi.repository import GLib
from utils.utils import setup_cursor_hover, svg_file
from utils.functions import is_app_running, kill_process
class RecordingIndicator(Button):
@@ -24,13 +23,8 @@ class RecordingIndicator(Button):
self.timer_timeout_id = None
self.status_timeout_id = None
self.recording_icon = Svg(
name="indicators-icon",
size=24,
svg_file=get_relative_path(
"../../../config/assets/icons/misc/media-record.svg"
),
)
self.recording_icon = svg_file("misc/media-record.svg", size=24)
self.time_label = Label(
name="recording-time-label",
markup="00:00",
@@ -47,9 +41,19 @@ class RecordingIndicator(Button):
self.add(self.recording_box)
# Prevent container.show_all() from forcing this visible when no recording
try:
self.set_no_show_all(True)
except Exception:
pass
self.connect("clicked", self.on_stop_recording)
try:
setup_cursor_hover(self, "pointer")
except Exception:
pass
self.connect("button-press-event", self.on_button_press)
self.hide()
self.set_visible(False)
GLib.timeout_add(100, self._delayed_init)
@@ -58,22 +62,13 @@ class RecordingIndicator(Button):
return False
def is_recorder_running(self):
# add more process names if needed
# Check if any recorder process is running
recorder_processes = ["wf-recorder", "gpu-screen-recorder"]
try:
for proc in recorder_processes:
result = subprocess.run(
["pgrep", "-x", proc],
capture_output=True,
text=True,
timeout=1,
)
if result.returncode == 0:
return True # Found a running recorder process
return False # None found running
except Exception:
return False
for proc in recorder_processes:
if is_app_running(proc):
return True
return False
def check_recording_status(self):
current_time = time.time()
@@ -95,9 +90,9 @@ class RecordingIndicator(Button):
self.update_timer_display()
else:
if self.get_visible():
self.set_visible(False)
self.cleanup_recording_state()
# Ensure it stays hidden when not recording
self.set_visible(False)
self.cleanup_recording_state()
except Exception as e:
print(f"[DEBUG] Error checking recording status: {e}")
@@ -172,11 +167,9 @@ class RecordingIndicator(Button):
def send_stop_command():
try:
subprocess.Popen(
[self.script_path, "record", "stop"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Kill any running recorder processes
kill_process("wf-recorder")
kill_process("gpu-screen-recorder")
except Exception as e:
print(f"[DEBUG] Error sending stop command: {e}")
+79
View File
@@ -0,0 +1,79 @@
from fabric.hyprland.widgets import HyprlandWorkspaces, WorkspaceButton
from fabric.widgets.box import Box
from services.config import get_config, on_config_change
from utils.functions import is_special_workspace_id
from utils.utils import setup_cursor_hover
# TODO: Support multi-monitor setups
class WorkspaceIndicator(Box):
def __init__(self, **kwargs):
Box.__init__(
self, name="workspace-indicator", orientation="h", spacing=4, **kwargs
)
self._current_config = {"hide_special_workspace": True}
on_config_change(self._on_config_changed)
self.workspaces = HyprlandWorkspaces(
name="workspaces",
spacing=4,
buttons_factory=self._get_button_factory(),
)
self.add(self.workspaces)
self.show_all()
self._apply_initial_config()
def _apply_initial_config(self):
new_value = get_config("hide_special_workspace", True)
if new_value != self._current_config["hide_special_workspace"]:
self._current_config["hide_special_workspace"] = new_value
self.update_config({"hide_special_workspace": new_value})
def _on_config_changed(self, new_config: dict, old_config: dict):
if "hide_special_workspace" in new_config and new_config.get(
"hide_special_workspace"
) != old_config.get("hide_special_workspace"):
self._current_config["hide_special_workspace"] = new_config.get(
"hide_special_workspace", True
)
self.update_config(
{
"hide_special_workspace": self._current_config[
"hide_special_workspace"
]
}
)
def _get_button_factory(self):
if self._current_config.get("hide_special_workspace", True):
return self._create_filtered_button
else:
return lambda ws_id: self._create_button_with_hover(ws_id)
def _create_filtered_button(self, ws_id):
if self._current_config.get(
"hide_special_workspace", True
) and is_special_workspace_id(ws_id):
return None
return self._create_button_with_hover(ws_id)
def _create_button_with_hover(self, ws_id):
button = WorkspaceButton(id=ws_id, label=str(ws_id))
setup_cursor_hover(button, "pointer")
return button
def update_config(self, new_config: dict):
if "hide_special_workspace" in new_config:
button_factory = self._get_button_factory()
self.remove(self.workspaces)
self.workspaces = HyprlandWorkspaces(
name="workspaces",
spacing=4,
buttons_factory=button_factory,
)
self.add(self.workspaces)
self.workspaces.show_all()