diff --git a/modules/about.py b/modules/about.py
index d213702..0511da2 100644
--- a/modules/about.py
+++ b/modules/about.py
@@ -1,9 +1,16 @@
import re
import subprocess
+import os
+import gi
+
+gi.require_version("GdkPixbuf", "2.0")
+gi.require_version("Gtk", "3.0")
from gi.repository import GdkPixbuf, Gtk # type: ignore
from fabric.utils.helpers import get_relative_path
+from utils.roam import modus_service
+from utils.icon_resolver import IconResolver
def read_dmi(field):
@@ -43,6 +50,339 @@ def get_gpu_name():
return "Unknown GPU"
+def get_executable_path(exec_string):
+ """Extract and find the actual executable path from Exec field"""
+ if not exec_string:
+ return None
+
+ # Remove common exec modifiers and arguments
+ exec_parts = exec_string.split()
+ if not exec_parts:
+ return None
+
+ executable = exec_parts[0]
+
+ # Remove common prefixes
+ prefixes_to_remove = ['env', 'bash', 'sh', '/usr/bin/env']
+ while executable in prefixes_to_remove and len(exec_parts) > 1:
+ exec_parts.pop(0)
+ executable = exec_parts[0]
+
+ # If it's already an absolute path, check if it exists
+ if executable.startswith('/'):
+ if os.path.exists(executable):
+ return executable
+ return None
+
+ # Search in PATH
+ try:
+ result = subprocess.run(['which', executable], capture_output=True, text=True)
+ if result.returncode == 0:
+ return result.stdout.strip()
+ except Exception:
+ pass
+
+ return None
+
+
+def get_app_info(wmclass):
+ """Get comprehensive application information from .desktop file"""
+ if not wmclass:
+ return {
+ "name": "Desktop",
+ "version": "",
+ "comment": "Desktop Environment",
+ "icon": "desktop",
+ "exec": "",
+ "location": "",
+ "categories": "",
+ "desktop_file": ""
+ }
+
+ desktop_paths = [
+ "/usr/share/applications",
+ "/var/lib/flatpak/exports/share/applications",
+ os.path.expanduser("~/.local/share/applications"),
+ "/usr/local/share/applications"
+ ]
+
+ for path in desktop_paths:
+ if not os.path.exists(path):
+ continue
+
+ # Try exact match first
+ exact_matches = [f for f in os.listdir(path)
+ if f.lower() == f"{wmclass.lower()}.desktop"]
+
+ # Then try starts with
+ startswith_matches = [f for f in os.listdir(path)
+ if f.startswith(wmclass.lower()) and f.endswith('.desktop')]
+
+ # Finally try contains
+ contains_matches = [f for f in os.listdir(path)
+ if wmclass.lower() in f.lower() and f.endswith('.desktop')]
+
+ # Process matches in order of preference
+ for matches in [exact_matches, startswith_matches, contains_matches]:
+ for filename in matches:
+ desktop_file = os.path.join(path, filename)
+ try:
+ with open(desktop_file, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ name = wmclass.title()
+ version = ""
+ comment = ""
+ icon = wmclass.lower()
+ exec_cmd = ""
+ categories = ""
+
+ # Parse desktop file
+ in_desktop_entry = False
+ for line in content.split('\n'):
+ line = line.strip()
+ if line == '[Desktop Entry]':
+ in_desktop_entry = True
+ continue
+ elif line.startswith('[') and line.endswith(']'):
+ in_desktop_entry = False
+ continue
+
+ if not in_desktop_entry or '=' not in line:
+ continue
+
+ key, value = line.split('=', 1)
+ if key == 'Name':
+ name = value
+ elif key == 'Version':
+ version = value
+ elif key == 'Comment':
+ comment = value
+ elif key == 'GenericName' and not comment:
+ # Use GenericName as fallback description
+ comment = value
+ elif key == 'Icon':
+ icon = value
+ elif key == 'Exec':
+ exec_cmd = value
+ elif key == 'Categories':
+ categories = value
+
+ # Get executable location
+ location = get_executable_path(exec_cmd)
+
+ return {
+ "name": name,
+ "version": version,
+ "comment": comment,
+ "icon": icon,
+ "exec": exec_cmd,
+ "location": location or "",
+ "categories": categories,
+ "desktop_file": desktop_file
+ }
+ except Exception:
+ continue
+
+ # Fallback: try to find executable in PATH
+ location = ""
+ try:
+ result = subprocess.run(['which', wmclass.lower()], capture_output=True, text=True)
+ if result.returncode == 0:
+ location = result.stdout.strip()
+ except Exception:
+ pass
+
+ return {
+ "name": wmclass.title() if wmclass else "Unknown Application",
+ "version": "",
+ "comment": "",
+ "icon": wmclass.lower() if wmclass else "application-x-executable",
+ "exec": "",
+ "location": location,
+ "categories": "",
+ "desktop_file": ""
+ }
+
+
+class AboutApp(Gtk.Window):
+ def __init__(self, app_name="Unknown Application", wmclass=""):
+ super().__init__(title=f"About {app_name}")
+ self.app_name = app_name
+ self.wmclass = wmclass
+ self.icon_resolver = IconResolver()
+
+ self.set_default_size(400, 500)
+ self.set_size_request(400, 480)
+ self.set_resizable(False)
+ self.set_title(f"About {app_name}")
+ self.set_name("about-app")
+ self.set_visible(False)
+
+ self.setup_ui()
+
+ def setup_ui(self):
+ app_info = get_app_info(self.wmclass)
+
+ main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
+ main_box.set_margin_top(20)
+ main_box.set_margin_bottom(20)
+ main_box.set_margin_start(25)
+ main_box.set_margin_end(25)
+
+ # App Icon
+ logo_box = Gtk.Box(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
+ try:
+ # Use IconResolver's get_icon_pixbuf method like other parts of the project
+ icon_pixbuf = self.icon_resolver.get_icon_pixbuf(app_info["icon"], 128)
+ if icon_pixbuf:
+ logo = Gtk.Image.new_from_pixbuf(icon_pixbuf)
+ else:
+ raise Exception("Icon pixbuf not found")
+ except Exception:
+ # Fallback: try direct file path if it's an absolute path
+ try:
+ if app_info["icon"].startswith('/') and os.path.exists(app_info["icon"]):
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
+ app_info["icon"], 128, 128, preserve_aspect_ratio=True
+ )
+ logo = Gtk.Image.new_from_pixbuf(pixbuf)
+ else:
+ raise Exception("Direct path failed")
+ except Exception:
+ # Final fallback: emoji
+ logo = Gtk.Label()
+ logo.set_markup("📱")
+
+ logo_box.pack_start(logo, False, False, 0)
+ logo_box.set_margin_bottom(15)
+
+ # App Name
+ app_name_label = Gtk.Label()
+ app_name_label.set_markup(f"{app_info['name']}")
+ app_name_label.set_halign(Gtk.Align.CENTER)
+ app_name_label.set_margin_bottom(5)
+
+ # Version (if available)
+ version_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ if app_info["version"]:
+ version_label = Gtk.Label(label=f"Version {app_info['version']}")
+ version_label.set_name("version-label")
+ version_label.set_halign(Gtk.Align.CENTER)
+ version_box.pack_start(version_label, False, False, 0)
+
+ version_box.set_margin_bottom(15)
+
+ # Description/Comment - Make it more prominent
+ description_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ if app_info["comment"]:
+ # Create a frame for the description to make it stand out
+ desc_frame = Gtk.Frame()
+ desc_frame.set_shadow_type(Gtk.ShadowType.IN)
+
+ description_label = Gtk.Label()
+ description_label.set_markup(f"{app_info['comment']}")
+ description_label.set_justify(Gtk.Justification.CENTER)
+ description_label.set_halign(Gtk.Align.CENTER)
+ description_label.set_line_wrap(True)
+ description_label.set_max_width_chars(45)
+ description_label.set_margin_top(8)
+ description_label.set_margin_bottom(8)
+ description_label.set_margin_start(12)
+ description_label.set_margin_end(12)
+
+ desc_frame.add(description_label)
+ description_box.pack_start(desc_frame, False, False, 0)
+ else:
+ # Show a placeholder if no description is available
+ placeholder_label = Gtk.Label()
+ placeholder_label.set_markup("No description available")
+ placeholder_label.set_halign(Gtk.Align.CENTER)
+ description_box.pack_start(placeholder_label, False, False, 0)
+
+ description_box.set_margin_bottom(15)
+
+ # Information Grid
+ info_frame = Gtk.Frame()
+ info_frame.set_label("Application Information")
+ info_frame.set_label_align(0.5, 0.5)
+
+ info_grid = Gtk.Grid()
+ info_grid.set_row_spacing(8)
+ info_grid.set_column_spacing(15)
+ info_grid.set_margin_top(10)
+ info_grid.set_margin_bottom(10)
+ info_grid.set_margin_start(15)
+ info_grid.set_margin_end(15)
+ info_grid.set_valign(Gtk.Align.CENTER)
+ info_grid.set_halign(Gtk.Align.FILL)
+
+ def make_info_row(label_text, value_text, row):
+ """Create a row in the info grid"""
+ label = Gtk.Label(label=f"{label_text}:")
+ label.set_halign(Gtk.Align.END)
+ label.set_markup(f"{label_text}:")
+
+ value = Gtk.Label(label=value_text)
+ value.set_halign(Gtk.Align.START)
+ value.set_line_wrap(True)
+ value.set_max_width_chars(30)
+ value.set_name("info-value-label")
+
+ info_grid.attach(label, 0, row, 1, 1)
+ info_grid.attach(value, 1, row, 1, 1)
+
+ row = 0
+
+ # Application Name
+ make_info_row("Name", app_info["name"], row)
+ row += 1
+
+ # Version
+ if app_info["version"]:
+ make_info_row("Version", app_info["version"], row)
+ row += 1
+
+ # Executable Location
+ if app_info["location"]:
+ make_info_row("Location", app_info["location"], row)
+ row += 1
+
+ # Window Class
+ if self.wmclass:
+ make_info_row("Window Class", self.wmclass, row)
+ row += 1
+
+ # Categories
+ if app_info["categories"]:
+ categories = app_info["categories"].replace(";", ", ").strip(", ")
+ make_info_row("Categories", categories, row)
+ row += 1
+
+ # Desktop File
+ if app_info["desktop_file"]:
+ desktop_file_name = os.path.basename(app_info["desktop_file"])
+ make_info_row("Desktop File", desktop_file_name, row)
+ row += 1
+
+ info_frame.add(info_grid)
+
+ # Layout
+ main_box.pack_start(logo_box, False, False, 0)
+ main_box.pack_start(app_name_label, False, False, 0)
+ main_box.pack_start(version_box, False, False, 0)
+ main_box.pack_start(description_box, False, False, 0)
+ main_box.pack_start(info_frame, False, False, 0)
+
+ self.add(main_box)
+
+ def toggle(self, b):
+ if self.get_visible():
+ self.hide()
+ else:
+ self.show_all()
+
+
class About(Gtk.Window):
def __init__(self):
super().__init__(title="About Menu")
diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py
index 4b185e1..e9d74ec 100644
--- a/modules/controlcenter/main.py
+++ b/modules/controlcenter/main.py
@@ -11,6 +11,7 @@ from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.svg import Svg
+from fabric.widgets.revealer import Revealer
from modules.controlcenter.bluetooth import BluetoothConnections
from modules.controlcenter.wifi import WifiConnections
from services.brightness import Brightness
@@ -350,15 +351,29 @@ class ModusControlCenter(Window):
self.per_app_volume_widgets = None
self.expanded_player_widgets = None
+ # Create main content boxes
self.center_box = CenterBox(start_children=[self.widgets])
self.bluetooth_center_box = None
self.wifi_center_box = None
self.per_app_volume_center_box = None
self.expanded_player_center_box = None
+ # Create revealers for crossfade transitions
+ self.main_revealer = Revealer(
+ child=self.center_box,
+ transition_type="crossfade",
+ transition_duration=250,
+ child_revealed=True,
+ )
+
+ self.expanded_player_revealer = None # Will be created lazily
+
self.widgets.set_size_request(300, -1)
- self.children = self.center_box
+ self.children = self.main_revealer
+
+ # Track current state for smooth transitions
+ self.current_view = "main" # main, expanded_player
# Connect to visibility changes for cleanup
self.connect("notify::visible", self._on_visibility_changed)
@@ -511,6 +526,15 @@ class ModusControlCenter(Window):
start_children=[self.expanded_player_widgets]
)
self.expanded_player_center_box.set_size_request(300, -1)
+
+ # Create revealer for expanded player
+ if self.expanded_player_revealer is None:
+ self.expanded_player_revealer = Revealer(
+ child=self.expanded_player_center_box,
+ transition_type="crossfade",
+ transition_duration=250,
+ child_revealed=False,
+ )
def set_dont_disturb(self, *_):
self.focus_mode = not self.focus_mode
@@ -632,35 +656,82 @@ class ModusControlCenter(Window):
self.has_wifi_open = True
def close_bluetooth(self, *_):
- idle_add(lambda *_: self.set_children(self.center_box))
+ if self.current_view == "expanded_player":
+ self._crossfade_to_view("main")
+ else:
+ idle_add(lambda *_: self.set_children(self.center_box))
self.has_bluetooth_open = False
def close_wifi(self, *_):
- idle_add(lambda *_: self.set_children(self.center_box))
+ if self.current_view == "expanded_player":
+ self._crossfade_to_view("main")
+ else:
+ idle_add(lambda *_: self.set_children(self.center_box))
self.has_wifi_open = False
def open_per_app_volume(self, *_):
self._ensure_per_app_volume_widgets()
- idle_add(lambda *_: self.set_children(self.per_app_volume_center_box))
+ if self.current_view == "expanded_player":
+ # If coming from expanded player, use crossfade
+ self._crossfade_to_view("main")
+ GLib.timeout_add(250, lambda: idle_add(lambda *_: self.set_children(self.per_app_volume_center_box)))
+ else:
+ idle_add(lambda *_: self.set_children(self.per_app_volume_center_box))
self.has_per_app_volume_open = True
# Refresh the app list when opening
if self._per_app_volume_widget:
self._per_app_volume_widget.refresh()
def close_per_app_volume(self, *_):
- idle_add(lambda *_: self.set_children(self.center_box))
+ if self.current_view == "expanded_player":
+ self._crossfade_to_view("main")
+ else:
+ idle_add(lambda *_: self.set_children(self.center_box))
self.has_per_app_volume_open = False
+ def _crossfade_to_view(self, target_view):
+ """Handle crossfade transition between views"""
+ if self.current_view == target_view:
+ return
+
+ if target_view == "expanded_player":
+ # Transitioning to expanded player
+ self._ensure_expanded_player_widgets()
+
+ # Add expanded player revealer to window if not already added
+ if self.expanded_player_revealer not in self.get_children():
+ self.add(self.expanded_player_revealer)
+
+ # Start crossfade: hide main, show expanded player
+ self.main_revealer.set_reveal_child(False)
+ GLib.timeout_add(125, lambda: self.expanded_player_revealer.set_reveal_child(True))
+
+ elif target_view == "main":
+ # Transitioning back to main
+ if self.expanded_player_revealer:
+ # Start crossfade: hide expanded player, show main
+ self.expanded_player_revealer.set_reveal_child(False)
+ GLib.timeout_add(125, lambda: self.main_revealer.set_reveal_child(True))
+ # Clean up expanded player revealer after transition
+ GLib.timeout_add(375, self._cleanup_expanded_player_revealer)
+
+ self.current_view = target_view
+
+ def _cleanup_expanded_player_revealer(self):
+ """Remove expanded player revealer from window after transition"""
+ if self.expanded_player_revealer and self.expanded_player_revealer in self.get_children():
+ self.remove(self.expanded_player_revealer)
+ return False
+
def open_expanded_player(self, *_):
- self._ensure_expanded_player_widgets()
- idle_add(lambda *_: self.set_children(self.expanded_player_center_box))
+ self._crossfade_to_view("expanded_player")
self.has_expanded_player_open = True
# Refresh the player when opening
if self._expanded_player_widget:
self._expanded_player_widget.refresh()
def close_expanded_player(self, *_):
- idle_add(lambda *_: self.set_children(self.center_box))
+ self._crossfade_to_view("main")
self.has_expanded_player_open = False
def _set_mousecapture(self, visible: bool):
diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py
index d99064b..f4f301c 100644
--- a/modules/panel/components/menubar.py
+++ b/modules/panel/components/menubar.py
@@ -1,5 +1,6 @@
import os
import subprocess
+import json
from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow
from fabric.utils import FormattedString
@@ -7,7 +8,7 @@ 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
+from modules.about import About, AboutApp
from utils.roam import modus_service
from widgets.dropdown import ModusDropdown, dropdown_divider
from widgets.mousecapture import DropDownMouseCapture
@@ -92,6 +93,31 @@ def format_window(title, wmclass):
return name
+def show_about_app():
+ """Show about dialog for current active application"""
+ try:
+ # Get current window info from Hyprland
+ result = subprocess.run(
+ ["hyprctl", "activewindow", "-j"],
+ capture_output=True,
+ text=True,
+ check=True
+ )
+ import json
+ window_info = json.loads(result.stdout)
+ wmclass = window_info.get("class", "")
+ title = window_info.get("title", "")
+ app_name = modus_service.current_active_app_name or "Unknown Application"
+
+ about_window = AboutApp(app_name=app_name, wmclass=wmclass)
+ about_window.toggle(None)
+ except Exception as e:
+ # Fallback: show about dialog with just the app name
+ app_name = modus_service.current_active_app_name or "Unknown Application"
+ about_window = AboutApp(app_name=app_name, wmclass="")
+ about_window.toggle(None)
+
+
def dropdown_option(
label: str = "",
keybind: str = "",
@@ -186,7 +212,8 @@ class MenuBarDropdowns:
# Global menu dropdowns
self.global_title_menu_about = dropdown_option(
- f"About {modus_service.current_active_app_name}"
+ f"About {modus_service.current_active_app_name}",
+ on_clicked=lambda _: show_about_app()
)
self.global_menu_title = DropDownMouseCapture(
layer="bottom",