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",