✨ feat(panel): enhance system tray menus with dynamic updates and submenu support

This commit is contained in:
sankalp committed 2025-08-04 10:30:03 +05:45
1 parent 1ec5d87477
commit 366e035395
1 file changed
+420 -58
+420 -58
View File
@@ -3,7 +3,13 @@ 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.
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
@@ -152,16 +158,232 @@ def _set_tooltip(self):
)
def dropdown_option(label: str = "", on_click=None):
"""Create a dropdown option for system tray items"""
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()
@@ -185,54 +407,173 @@ def dropdown_option(label: str = "", on_click=None):
)
# 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"""
"""Recursively extract menu items from a DbusmenuGtk3.Menu with state tracking"""
options = []
try:
if hasattr(menu, 'get_children'):
if hasattr(menu, "get_children"):
children = menu.get_children()
for child in children:
try:
# Get menu item properties
label = ""
if hasattr(child, 'property_get'):
if hasattr(child, "property_get"):
label = child.property_get("label") or ""
elif hasattr(child, 'get_label'):
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":
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'):
if hasattr(menu_child, "handle_event"):
menu_child.handle_event("clicked", None, 0)
elif hasattr(menu_child, 'activate'):
elif hasattr(menu_child, "activate"):
menu_child.activate()
except Exception:
pass
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
options.append(dropdown_option(
label.replace("_", ""), # Remove mnemonics
on_click=create_menu_action(child)
))
# Handle submenus
if hasattr(child, 'get_submenu'):
submenu_items = None
has_native_submenu = False
if hasattr(child, "get_submenu"):
submenu = child.get_submenu()
if submenu:
sub_options = extract_menu_items(submenu, tray_item)
if sub_options:
options.extend(sub_options)
submenu_items = extract_menu_items(submenu, tray_item)
if submenu_items:
print(
f"[DEBUG] Found submenu for '{label}' with {
len(submenu_items)
} items"
)
except Exception:
# 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:
@@ -250,29 +591,35 @@ def get_tray_menu_options(tray_item):
def activate_app():
try:
tray_item._item.activate_for_event(None)
except Exception:
pass
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:
pass
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'):
if hasattr(tray_item._item, "menu"):
menu = tray_item._item.menu
elif hasattr(tray_item._item, 'get_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)
@@ -281,44 +628,61 @@ def get_tray_menu_options(tray_item):
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
options.append(dropdown_option(
f"Show {app_name} Menu",
on_click=show_native_menu
))
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
options.append(dropdown_option(
f"Open {app_name}",
on_click=activate_app
))
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
))
options.append(
dropdown_option("Show Context Menu", on_click=show_native_menu)
)
except Exception:
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_option(f"Open {app_name}", on_click=activate_app),
dropdown_divider(""),
dropdown_option(
"Show Context Menu",
on_click=show_native_menu
),
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)
@@ -332,10 +696,10 @@ def create_system_tray_dropdown(tray_item, parent=None):
dropdown_children=dropdown_children,
)
dropdown_menu = DropDownMouseCapture(
layer="bottom",
child_window=dropdown
)
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):
@@ -374,15 +738,13 @@ def create_system_tray_dropdown(tray_item, parent=None):
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:
if not hasattr(self, '_dropdown_capture'):
# Try to find the parent window by traversing up the widget hierarchy
parent = self.get_toplevel()
self._dropdown_capture = create_system_tray_dropdown(self, parent)
# 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()