✨ feat(panel): enhance system tray menus with dynamic updates and submenu support
This commit is contained in:
1 parent
1ec5d87477
commit
366e035395
1 file changed
+420
-58
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user