Files
Modus/modules/panel/components/enhanced_system_tray.py
T

775 lines
29 KiB
Python

"""
Enhanced System Tray Icon Handling
This module provides enhanced icon loading capabilities for system tray items,
including fallback mechanisms for file paths and common icon locations.
It also adds dropdown menu functionality for system tray items with:
- Dynamic menu state tracking (checkboxes, radio buttons)
- Real-time menu refresh on each right-click
- Proper toggle state visualization (✓ for checked, ● for radio selected)
- Enhanced error handling and debugging
- Support for enabled/disabled menu items
"""
import os
from gi.repository import GdkPixbuf, Gtk
from fabric.widgets.label import Label
from fabric.system_tray.widgets import SystemTrayItem
from widgets.dropdown import ModusDropdown, dropdown_divider
from widgets.mousecapture import DropDownMouseCapture
# FIX: the tooltip should show application names instead of unknown
def patched_do_update_properties(self, *_):
# Try default GTK theme first
icon_name = self._item.icon_name
attention_icon_name = self._item.attention_icon_name
if self._item.status == "NeedsAttention" and attention_icon_name:
preferred_icon_name = attention_icon_name
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
)
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
# Enhanced fallback handling for file paths
if preferred_icon_name and self._try_load_icon_from_path(preferred_icon_name):
return
# 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"
)
def dropdown_option(
label: str = "",
on_click=None,
submenu_items=None,
has_native_submenu=False,
native_menu_item=None,
):
"""Create a dropdown option for system tray items with optional submenu"""
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.box import Box
# If this has native submenu (like NetworkManager WiFi list), create special expandable option
if has_native_submenu and native_menu_item:
from fabric.widgets.revealer import Revealer
# State for submenu expansion
submenu_expanded = False
def toggle_native_submenu(_):
nonlocal submenu_expanded
submenu_expanded = not submenu_expanded
submenu_revealer.set_reveal_child(submenu_expanded)
# Update the arrow indicator
arrow_label = "▼" if submenu_expanded else "▶"
main_button.get_child().get_start_children()[0].set_label(
f"{label} {arrow_label}"
)
print(
f"[DEBUG] Native submenu {
'expanded' if submenu_expanded else 'collapsed'
}"
)
def show_native_menu(_):
"""Show the native context menu for this item"""
try:
print(f"[DEBUG] Showing native menu for '{label}'")
# Hide our dropdown first
from widgets.dropdown import dropdowns
for dropdown in dropdowns:
if dropdown.is_visible() and hasattr(
dropdown, "hide_via_mousecapture"
):
dropdown.hide_via_mousecapture()
break
# Try to activate the native menu item
if hasattr(native_menu_item, "activate"):
native_menu_item.activate()
elif hasattr(native_menu_item, "handle_event"):
native_menu_item.handle_event("clicked", None, 0)
except Exception as e:
print(f"[DEBUG] Failed to show native menu for '{label}': {e}")
# Create main button with arrow indicator
main_button = Button(
child=CenterBox(
start_children=[
Label(
label=f"{label} ▶",
h_align="start",
name="dropdown-option-label",
),
],
orientation="horizontal",
h_align="fill",
h_expand=True,
v_expand=True,
),
name="dropdown-option",
h_align="fill",
on_clicked=toggle_native_submenu,
h_expand=True,
v_expand=True,
)
# Create submenu with native menu option
native_submenu_items = [
Button(
child=CenterBox(
start_children=[
Label(
label=" Show Native Menu",
h_align="start",
name="dropdown-option-label",
),
],
orientation="horizontal",
h_align="fill",
h_expand=True,
v_expand=True,
),
name="dropdown-option",
h_align="fill",
on_clicked=show_native_menu,
h_expand=True,
v_expand=True,
),
Button(
child=CenterBox(
start_children=[
Label(
label=" (Hover over tray icon for full menu)",
h_align="start",
name="dropdown-option-label",
),
],
orientation="horizontal",
h_align="fill",
h_expand=True,
v_expand=True,
),
name="dropdown-option",
h_align="fill",
sensitive=False, # Disabled info text
h_expand=True,
v_expand=True,
),
]
# Create revealer for submenu
submenu_revealer = Revealer(
child=Box(
children=native_submenu_items,
orientation="vertical",
name="submenu-items",
),
reveal_child=False,
transition_type="slide-down",
transition_duration=200,
)
# Return container with main button and submenu
return Box(
children=[main_button, submenu_revealer],
orientation="vertical",
name="dropdown-option-with-submenu",
)
# If this has submenu items, create an expandable option
elif submenu_items:
from fabric.widgets.revealer import Revealer
# State for submenu expansion
submenu_expanded = False
def toggle_submenu(_):
nonlocal submenu_expanded
submenu_expanded = not submenu_expanded
submenu_revealer.set_reveal_child(submenu_expanded)
# Update the arrow indicator
arrow_label = "▼" if submenu_expanded else "▶"
main_button.get_child().get_start_children()[0].set_label(
f"{label} {arrow_label}"
)
print(f"[DEBUG] Submenu {'expanded' if submenu_expanded else 'collapsed'}")
# Create main button with arrow indicator
main_button = Button(
child=CenterBox(
start_children=[
Label(
label=f"{label} ▶",
h_align="start",
name="dropdown-option-label",
),
],
orientation="horizontal",
h_align="fill",
h_expand=True,
v_expand=True,
),
name="dropdown-option",
h_align="fill",
on_clicked=toggle_submenu,
h_expand=True,
v_expand=True,
)
# Create submenu items with indentation
indented_submenu_items = []
for item in submenu_items:
# Add indentation to submenu items
if hasattr(item, "get_child") and hasattr(
item.get_child(), "get_start_children"
):
original_label = item.get_child().get_start_children()[0]
if hasattr(original_label, "get_label"):
original_text = original_label.get_label()
original_label.set_label(f" {original_text}") # Add indentation
indented_submenu_items.append(item)
# Create revealer for submenu
submenu_revealer = Revealer(
child=Box(
children=indented_submenu_items,
orientation="vertical",
name="submenu-items",
),
reveal_child=False,
transition_type="slide-down",
transition_duration=200,
)
# Return container with main button and submenu
return Box(
children=[main_button, submenu_revealer],
orientation="vertical",
name="dropdown-option-with-submenu",
)
# Regular option without submenu
def on_click_handler(_):
if on_click:
on_click()
# Hide dropdown after action
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"),
],
orientation="horizontal",
h_align="fill",
h_expand=True,
v_expand=True,
),
name="dropdown-option",
h_align="fill",
on_clicked=on_click_handler,
h_expand=True,
v_expand=True,
)
# Note: Submenu functionality is now handled inline within dropdown_option
# using Revealer widgets for expandable menu sections
def extract_menu_items(menu, tray_item):
"""Recursively extract menu items from a DbusmenuGtk3.Menu with state tracking"""
options = []
try:
if hasattr(menu, "get_children"):
children = menu.get_children()
for child in children:
try:
# Get menu item properties
label = ""
if hasattr(child, "property_get"):
label = child.property_get("label") or ""
elif hasattr(child, "get_label"):
label = child.get_label() or ""
# Skip empty labels and separators
if not label or label.strip() == "":
if (
hasattr(child, "property_get")
and child.property_get("type") == "separator"
):
options.append(dropdown_divider(""))
continue
# Check for toggle state and other properties
toggle_type = None
toggle_state = None
enabled = True
visible = True
if hasattr(child, "property_get"):
toggle_type = child.property_get("toggle-type")
toggle_state = child.property_get("toggle-state")
enabled = child.property_get("enabled")
visible = child.property_get("visible")
# Handle different property formats
if enabled is None:
enabled = True
if visible is None:
visible = True
# Debug output for complex menu items
if toggle_type or toggle_state is not None:
print(
f"[DEBUG] Menu item '{label}': toggle_type={
toggle_type
}, toggle_state={toggle_state}, enabled={enabled}"
)
# Skip invisible items
if not visible:
continue
# Format label based on toggle state
display_label = label.replace("_", "") # Remove mnemonics
if toggle_type == "checkmark" and toggle_state is not None:
# Add checkmark for checked items
if toggle_state == 1 or toggle_state is True: # Checked
display_label = f"✓ {display_label}"
else: # Unchecked
display_label = f" {display_label}"
elif toggle_type == "radio" and toggle_state is not None:
# Add radio button indicator
if toggle_state == 1 or toggle_state is True: # Selected
display_label = f"● {display_label}"
else: # Not selected
display_label = f"○ {display_label}"
# Create action for this menu item
def create_menu_action(menu_child):
def action():
try:
if hasattr(menu_child, "handle_event"):
menu_child.handle_event("clicked", None, 0)
elif hasattr(menu_child, "activate"):
menu_child.activate()
else:
print(
f"[DEBUG] No activation method found for menu item: {label}"
)
except Exception as e:
print(
f"[DEBUG] Failed to activate menu item '{label}': {
e
}"
)
return action
# Handle submenus
submenu_items = None
has_native_submenu = False
if hasattr(child, "get_submenu"):
submenu = child.get_submenu()
if submenu:
submenu_items = extract_menu_items(submenu, tray_item)
if submenu_items:
print(
f"[DEBUG] Found submenu for '{label}' with {
len(submenu_items)
} items"
)
# Check for potential native submenus (common patterns)
if not submenu_items and hasattr(child, "property_get"):
# Check for indicators that this might have a native submenu
item_type = child.property_get("type") or ""
children_display = child.property_get("children-display") or ""
# Common patterns for items that show native submenus on hover
submenu_indicators = [
"WiFi Networks" in label,
"Available Networks" in label,
"Connect to" in label,
"Bluetooth" in label
and ("Device" in label or "Connect" in label),
"Audio" in label
and ("Device" in label or "Output" in label),
"VPN" in label and "Connect" in label,
children_display == "submenu",
item_type == "submenu",
# Additional patterns for common system tray items
label.endswith("...")
and (
"Network" in label
or "Audio" in label
or "Bluetooth" in label
),
"Select" in label
and ("Device" in label or "Network" in label),
]
if any(submenu_indicators):
has_native_submenu = True
print(
f"[DEBUG] Detected potential native submenu for '{label}'"
)
# Create dropdown option with proper state indication and submenu
option = dropdown_option(
display_label,
on_click=(
create_menu_action(child)
if not submenu_items and not has_native_submenu
else None
),
submenu_items=submenu_items,
has_native_submenu=has_native_submenu,
native_menu_item=child if has_native_submenu else None,
)
# Disable option if not enabled
if not enabled:
option.set_sensitive(False)
options.append(option)
except Exception as e:
print(f"[DEBUG] Error processing menu item: {e}")
continue
except Exception:
pass
return options
def get_tray_menu_options(tray_item):
"""Extract menu options from the system tray item's native menu"""
options = []
app_name = tray_item._item.title or tray_item._item.identifier or "Unknown App"
# Create unique functions for this specific tray item to avoid closure issues
def activate_app():
try:
tray_item._item.activate_for_event(None)
except Exception as e:
print(f"[DEBUG] Failed to activate {app_name}: {e}")
def show_native_menu():
try:
tray_item._item.invoke_menu_for_event(None)
except Exception as e:
print(f"[DEBUG] Failed to show native menu for {app_name}: {e}")
try:
# Try to get the actual menu items from the tray item
menu = None
if hasattr(tray_item._item, "menu"):
menu = tray_item._item.menu
elif hasattr(tray_item._item, "get_menu"):
menu = tray_item._item.get_menu()
menu_extraction_successful = False
if menu is not None:
print(f"[DEBUG] Extracting menu for {app_name}, menu type: {type(menu)}")
# Extract actual menu items
extracted_options = extract_menu_items(menu, tray_item)
if extracted_options:
print(
f"[DEBUG] Successfully extracted {
len(extracted_options)
} menu items for {app_name}"
)
# Add dividers after each option (except the last one)
for i, option in enumerate(extracted_options):
options.append(option)
# Add divider after each option except the last one
if i < len(extracted_options) - 1:
options.append(dropdown_divider(""))
menu_extraction_successful = True
else:
print(f"[DEBUG] No menu items extracted for {app_name}")
# Only add fallback options if menu extraction was not successful
if not menu_extraction_successful:
if menu is not None:
# Menu exists but extraction failed, show native menu option
print(
f"[DEBUG] Menu exists but extraction failed for {
app_name
}, adding fallback"
)
options.append(
dropdown_option(f"Show {app_name} Menu", on_click=show_native_menu)
)
else:
# No menu available, just add basic activation
print(f"[DEBUG] No menu available for {app_name}, adding basic options")
options.append(
dropdown_option(f"Open {app_name}", on_click=activate_app)
)
options.append(dropdown_divider(""))
options.append(
dropdown_option("Show Context Menu", on_click=show_native_menu)
)
except Exception as e:
print(f"[DEBUG] Exception while extracting menu for {app_name}: {e}")
# Fallback to basic options if anything fails
options = [
dropdown_option(f"Open {app_name}", on_click=activate_app),
dropdown_divider(""),
dropdown_option("Show Context Menu", on_click=show_native_menu),
]
return options
def refresh_dropdown_menu(dropdown, tray_item):
"""Refresh the dropdown menu with current menu state"""
try:
# Get fresh menu options
fresh_options = get_tray_menu_options(tray_item)
# Update the dropdown children
if hasattr(dropdown, "child_window") and hasattr(
dropdown.child_window, "dropdown"
):
dropdown.child_window.dropdown.children = fresh_options
return True
except Exception:
return False
def create_system_tray_dropdown(tray_item, parent=None):
"""Create a dropdown menu for a system tray item"""
dropdown_children = get_tray_menu_options(tray_item)
# Create unique ID using both identifier and object id to ensure uniqueness
unique_id = f"systray-{tray_item._item.identifier}-{id(tray_item)}"
dropdown = ModusDropdown(
parent=parent,
dropdown_id=unique_id,
dropdown_children=dropdown_children,
)
dropdown_menu = DropDownMouseCapture(layer="bottom", child_window=dropdown)
# Store reference to tray item for refreshing
dropdown_menu._tray_item = tray_item
# Custom positioning for system tray items
def custom_get_coords_for_widget(widget):
"""Custom coordinate calculation for system tray items"""
if not ((toplevel := widget.get_toplevel()) and toplevel.is_toplevel()):
return 0, 0
# Get the widget's allocation and position
allocation = widget.get_allocation()
# Try to get absolute position on screen
try:
# Get widget position relative to toplevel - this should give us the correct coordinates
widget_x, widget_y = widget.translate_coordinates(toplevel, 0, 0) or (0, 0)
# Position the dropdown below the tray item
# Don't divide by 2 like the original method does - that's causing the positioning issue
return widget_x, widget_y + allocation.height
except Exception:
# Fallback to basic positioning
return 0, allocation.height
# Override the get_coords_for_widget method for this specific dropdown window
# Store the original method in case we need it
dropdown._original_get_coords_for_widget = dropdown.get_coords_for_widget
# Replace with our custom method
def bound_custom_get_coords(widget):
return custom_get_coords_for_widget(widget)
dropdown.get_coords_for_widget = bound_custom_get_coords
dropdown_menu.child_window.set_pointing_to(tray_item)
return dropdown_menu
def patched_on_clicked(self, widget, event):
"""Enhanced click handler that adds dropdown functionality"""
# Right click shows our custom dropdown instead of native menu
if event.button == 3:
# Always refresh the dropdown to get current menu state
parent = self.get_toplevel()
self._dropdown_capture = create_system_tray_dropdown(self, parent)
# Toggle the dropdown (positioning is handled by custom positioning function)
self._dropdown_capture.toggle_mousecapture()
return True
# For left clicks and other buttons, use original behavior
return original_on_clicked(self, widget, event)
def apply_enhanced_system_tray():
# Store original methods
global original_do_update_properties, original_on_clicked
original_do_update_properties = SystemTrayItem.do_update_properties
original_on_clicked = SystemTrayItem.on_clicked
# Attach helper methods to SystemTrayItem class
SystemTrayItem._try_load_icon_from_path = _try_load_icon_from_path
SystemTrayItem._set_tooltip = _set_tooltip
# Replace the methods
SystemTrayItem.do_update_properties = patched_do_update_properties
SystemTrayItem.on_clicked = patched_on_clicked
# Store references to original methods
original_do_update_properties = None
original_on_clicked = None