From d2d6a4c7c72532ecb868de35804021ea84286dfb Mon Sep 17 00:00:00 2001 From: Dexer <73297572+DexerBR@users.noreply.github.com> Date: Fri, 9 May 2025 12:13:27 -0300 Subject: [PATCH 1/5] wip --- .gitignore | 1 + examples/system_tray/system_tray_showcase.py | 368 ++++++++++ kivy/core/tray/__init__.py | 2 + kivy/core/tray/_tray_sdl3.pyx | 732 +++++++++++++++++++ kivy/lib/sdl3.pxi | 57 ++ setup.py | 1 + 6 files changed, 1161 insertions(+) create mode 100644 examples/system_tray/system_tray_showcase.py create mode 100644 kivy/core/tray/__init__.py create mode 100644 kivy/core/tray/_tray_sdl3.pyx diff --git a/.gitignore b/.gitignore index a3c323adb1..515ac266b8 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ kivy/setupconfig.py kivy/core/clipboard/_clipboard_sdl3.c kivy/graphics/egl_backend/egl_angle.c +kivy/core/tray/_tray_sdl3.c diff --git a/examples/system_tray/system_tray_showcase.py b/examples/system_tray/system_tray_showcase.py new file mode 100644 index 0000000000..abda3013d9 --- /dev/null +++ b/examples/system_tray/system_tray_showcase.py @@ -0,0 +1,368 @@ +import os +from kivy.app import App +from kivy.core.tray import TrayIcon, TrayMenu, TrayMenuItem +from kivy.lang import Builder +from kivy.logger import Logger +from kivy.properties import ObjectProperty +from kivy.resources import resource_add_path, resource_find +from kivy.uix.boxlayout import BoxLayout + +Builder.load_string(""" +: + orientation: 'vertical' + padding: 10 + spacing: 10 + Label: + id: status_label + text: "Status: Ready" + size_hint: 1, 0.4 + font_size: dp(18) + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.1 + spacing: 5 + TextInput: + id: item_label_input + hint_text: "Item name" + multiline: False + size_hint: 0.5, 1 + TextInput: + id: item_index_input + hint_text: "Index (optional)" + multiline: False + input_filter: 'int' + size_hint: 0.2, 1 + Button: + text: "Add" + size_hint: 0.3, 1 + on_press: root.add_item_from_input() + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.1 + spacing: 5 + TextInput: + id: remove_label_input + hint_text: "Name to remove" + multiline: False + size_hint: 0.7, 1 + Button: + text: "Remove" + size_hint: 0.3, 1 + on_press: root.remove_item_from_input() + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.1 + spacing: 5 + TextInput: + id: tooltip_input + hint_text: "Enter new tooltip text" + multiline: False + size_hint: 0.7, 1 + Button: + text: "Update Tooltip" + size_hint: 0.3, 1 + on_press: root.update_tooltip() + BoxLayout: + orientation: 'horizontal' + size_hint: 1, 0.1 + spacing: 5 + TextInput: + id: icon_path_input + hint_text: "Path to icon file (png, ico, jpg, jpeg, gif, svg)" + multiline: False + size_hint: 0.7, 1 + Button: + text: "Change Icon" + size_hint: 0.3, 1 + on_press: root.change_icon_from_path() + Button: + text: "Add Dynamic Item" + size_hint: 1, 0.1 + on_press: root.add_dynamic_item() + Button: + text: "Create Submenu" + size_hint: 1, 0.1 + on_press: root.add_submenu() + Button: + text: "Clear Menu" + size_hint: 1, 0.1 + on_press: root.clear_menu() + Button: + text: "Rebuild Default Menu" + size_hint: 1, 0.1 + on_press: root.rebuild_default_menu() +""") + + +class TrayManagerApp(App): + def build(self): + main_ui = MainUI() + main_ui.app = self + main_ui.build_default_menu() + return main_ui + + def on_stop(self): + """Removes the system tray icon when the application is closed""" + if hasattr(self.root, "tray_icon") and self.root.tray_icon: + self.root.tray_icon.destroy() + + +class MainUI(BoxLayout): + """Main interface for managing the system tray menu""" + + app = ObjectProperty(None) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.dynamic_items = [] + self.current_icon_path = resource_find("data/logo/kivy-icon-32.png") + + def build_default_menu(self): + """Builds the default initial system tray menu""" + self.tray_menu = TrayMenu() + + # Checkbox item to demonstrate states + self.notifications_item = TrayMenuItem( + label="Notifications", + type="checkbox", + checked=True, + callback=self.on_menu_item_clicked, + ) + self.tray_menu.add_item(self.notifications_item) + + # Separator to visually organize the menu + self.tray_menu.add_item(TrayMenuItem(type="separator")) + + # Simple button item + self.about_item = TrayMenuItem( + label="About", type="button", callback=self.on_menu_item_clicked + ) + self.tray_menu.add_item(self.about_item) + + self.tray_menu.add_item(TrayMenuItem(type="separator")) + + # Exit application item + self.exit_item = TrayMenuItem( + label="Exit", type="button", callback=lambda x: self.app.stop() + ) + self.tray_menu.add_item(self.exit_item) + + # Create the system tray icon if it doesn't exist + if not hasattr(self, "tray_icon") or not self.tray_icon: + self.tray_icon = TrayIcon( + icon_path=self.current_icon_path, + tooltip="Tray Menu Manager", + menu=self.tray_menu, + ) + if not self.tray_icon.create(): + Logger.error("MainUI: Failed to create tray icon") + + def on_menu_item_clicked(self, item): + """Callback for menu items when clicked""" + Logger.info(f"MainUI: Item '{item.label}' clicked") + + if item.type == "checkbox": + state = "enabled" if item.checked else "disabled" + self.ids.status_label.text = f"{item.label}: {state}" + else: + self.ids.status_label.text = f"Clicked: {item.label}" + + def add_item_from_input(self): + """Adds an item based on input text, with option to specify an index""" + label_text = self.ids.item_label_input.text.strip() + if not label_text: + self.ids.status_label.text = "Enter a name for the item" + return + + # Check the index (optional) + index_text = self.ids.item_index_input.text.strip() + index = -1 # add to end as default + menu_items = self.tray_menu.get_items() + if index_text: + try: + index = int(index_text) + if index < 0 or index > len(menu_items): + self.ids.status_label.text = ( + f"Invalid index. Use 0-{len(menu_items)}" + ) + return + except ValueError: + self.ids.status_label.text = "Index must be an integer" + return + + # Create and add the new item + new_item = TrayMenuItem( + label=label_text, + type="button", + callback=self.on_menu_item_clicked, + ) + self.tray_menu.add_item(new_item, index) + + # Update status and clear inputs + status_msg = ( + f"Item added at {index}: {label_text}" + if index >= 0 + else f"Item added: {label_text}" + ) + self.ids.status_label.text = status_msg + self.ids.item_label_input.text = "" + self.ids.item_index_input.text = "" + + def remove_item_from_input(self): + """Removes an item based on input text""" + label_text = self.ids.remove_label_input.text.strip() + if not label_text: + self.ids.status_label.text = "Enter the name of the item to remove" + return + + # Find and remove the item + menu_items = self.tray_menu.get_items() + for item in list(menu_items): + if item.label == label_text: + self.tray_menu.remove_item(item) + self.ids.status_label.text = f"Item removed: {label_text}" + self.ids.remove_label_input.text = "" + return + + self.ids.status_label.text = f"Item not found: {label_text}" + + def add_dynamic_item(self): + """Adds a new dynamic item to the menu""" + menu_items = self.tray_menu.get_items() + counter = len( + [i for i in menu_items if i.label and i.label.startswith("Dynamic")] + ) + + new_item = TrayMenuItem( + label=f"Dynamic {counter + 1}", + type="button", + callback=self.on_menu_item_clicked, + ) + self.tray_menu.add_item(new_item) + self.dynamic_items.append(new_item) + self.ids.status_label.text = f"Added: {new_item.label}" + + def add_submenu(self): + """Adds a submenu with multiple items to demonstrate submenu functionality""" + submenu = TrayMenu() + + # Create submenu items of different types + option1 = TrayMenuItem( + label="Option 1", + type="button", + callback=self.on_menu_item_clicked, + ) + option2 = TrayMenuItem( + label="Option 2", + type="checkbox", + checked=True, + callback=self.on_menu_item_clicked, + ) + submenu.add_item(option1) + submenu.add_item(option2) + self.tray_menu.add_item(TrayMenuItem(type="separator")) + + option3 = TrayMenuItem( + label="Option 3", + type="button", + callback=self.on_menu_item_clicked, + ) + submenu.add_item(option3) + + # Name the submenu dynamically + menu_items = self.tray_menu.get_items() + counter = len( + [i for i in menu_items if i.label and i.label.startswith("Submenu")] + ) + submenu_item = TrayMenuItem( + label=f"Submenu {counter + 1}", type="submenu", menu=submenu + ) + self.tray_menu.add_item(submenu_item) + self.ids.status_label.text = f"Submenu added: {submenu_item.label}" + + def clear_menu(self): + """Clears all items from the menu""" + self.tray_menu.clear() + self.dynamic_items = [] + self.ids.status_label.text = "Menu cleared" + + def rebuild_default_menu(self): + """Rebuilds the original default menu""" + self.tray_menu.clear() + self.dynamic_items = [] + + # Recreate the essential menu items + self.notifications_item = TrayMenuItem( + label="Notifications", + type="checkbox", + checked=True, + callback=self.on_menu_item_clicked, + ) + self.tray_menu.add_item(self.notifications_item) + self.tray_menu.add_item(TrayMenuItem(type="separator")) + self.about_item = TrayMenuItem( + label="About", type="button", callback=self.on_menu_item_clicked + ) + self.tray_menu.add_item(self.about_item) + self.tray_menu.add_item(TrayMenuItem(type="separator")) + self.exit_item = TrayMenuItem( + label="Exit", type="button", callback=lambda x: self.app.stop() + ) + self.tray_menu.add_item(self.exit_item) + self.ids.status_label.text = "Default menu rebuilt" + + def update_tooltip(self): + """Updates the tooltip text of the system tray icon""" + new_tooltip = self.ids.tooltip_input.text.strip() + if not new_tooltip: + self.ids.status_label.text = "Please enter tooltip text" + return + self.tray_icon.tooltip = new_tooltip + self.ids.status_label.text = f"Tooltip updated: {new_tooltip}" + self.ids.tooltip_input.text = "" + + def change_icon_from_path(self): + """Changes the tray icon using a provided path""" + icon_path = self.ids.icon_path_input.text.strip() + if not icon_path: + self.ids.status_label.text = ( + "Please enter a valid path to an icon file" + ) + return + + # Check if the file exists + if not os.path.exists(icon_path): + self.ids.status_label.text = f"File not found: {icon_path}" + return + + # Check if it's a valid image file + valid_extensions = [".png", ".ico", ".jpg", ".jpeg", ".gif", ".svg"] + file_ext = os.path.splitext(icon_path)[1].lower() + if file_ext not in valid_extensions: + self.ids.status_label.text = f"Not a valid image file: {file_ext}" + return + + try: + # Destroy and recreate the tray icon with the new icon + if hasattr(self, "tray_icon") and self.tray_icon: + self.tray_icon.destroy() + self.current_icon_path = icon_path + self.tray_icon = TrayIcon( + icon_path=self.current_icon_path, + tooltip="Tray Menu Manager", + menu=self.tray_menu, + ) + if not self.tray_icon.create(): + Logger.error("MainUI: Failed to create tray icon with new icon") + self.ids.status_label.text = "Failed to apply new icon" + return + self.ids.status_label.text = ( + f"Icon changed: {os.path.basename(icon_path)}" + ) + except Exception as e: + Logger.error(f"MainUI: Error changing icon: {e}") + self.ids.status_label.text = f"Error changing icon: {str(e)}" + + +if __name__ == "__main__": + TrayManagerApp().run() diff --git a/kivy/core/tray/__init__.py b/kivy/core/tray/__init__.py new file mode 100644 index 0000000000..ef131ed549 --- /dev/null +++ b/kivy/core/tray/__init__.py @@ -0,0 +1,2 @@ +from kivy.core.tray._tray_sdl3 import * + diff --git a/kivy/core/tray/_tray_sdl3.pyx b/kivy/core/tray/_tray_sdl3.pyx new file mode 100644 index 0000000000..21f6eabd17 --- /dev/null +++ b/kivy/core/tray/_tray_sdl3.pyx @@ -0,0 +1,732 @@ +include "../../../kivy/lib/sdl3.pxi" +include "../../include/config.pxi" + +from os import environ +from kivy.config import Config +from kivy.logger import Logger +from kivy.graphics.cgl cimport * +from libc.stdint cimport uintptr_t +from kivy.logger import Logger +from kivy.resources import resource_find +from libc.stdint cimport uintptr_t +import weakref +# from kivy.core.window import WindowBase + + +if not environ.get('KIVY_DOC_INCLUDE'): + is_desktop = Config.get('kivy', 'desktop') == '1' + + +# Global dictionary to track system tray menu items +# Stores weak references to prevent memory leaks +_tray_menu_registry = weakref.WeakValueDictionary() + + +cdef void _tray_item_callback(void *userdata, SDL_TrayEntry *entry) nogil: + """ + C callback function invoked when a tray menu item is clicked. + + Args: + userdata: Pointer containing user data (not used) + entry: SDL_TrayEntry that was clicked + """ + with gil: + # Find the menu item for this entry + addr = entry + py_addr = addr + menu_item = _tray_menu_registry.get(py_addr) + + if menu_item is None: + Logger.warning(f"TrayIcon: No menu item found for pointer {entry}") + return + + # For checkbox items, toggle the checked state + if menu_item.type == 'checkbox': + menu_item.checked = not menu_item.checked + + # Call the menu item callback if it exists + if menu_item.callback: + try: + menu_item.callback(menu_item) + except Exception as e: + Logger.error(f"TrayIcon: Exception in callback: {e}") + + +cdef class _SDLPointerHandler: + + cdef uintptr_t __pointer + + def __cinit__(self): + self.__pointer = 0 # Initialized as NULL + + cdef uintptr_t get_pointer(self): + """Gets the SDL pointer associated with this item.""" + return self.__pointer + + cdef void set_pointer(self, uintptr_t value): + """Sets the SDL pointer associated with this item.""" + if value < 0: + raise ValueError("Invalid pointer value") + self.__pointer = value + self._on_pointer_set(value) + + cdef void _on_pointer_set(self, uintptr_t value): + pass + + cdef bint has_valid_pointer(self): + """Returns whether this object has a valid SDL pointer.""" + return self.__pointer != 0 + + cpdef bint _can_update(self): + """Returns whether this object can be updated via the tray icon handler.""" + global _tray_icon_handler + return self.has_valid_pointer() and _tray_icon_handler is not None + + +cdef class TrayMenuItem(_SDLPointerHandler): + """ + Represents a single item in a system tray menu. + + Properties: + label (str): The text shown on the menu item + enabled (bool): Whether the menu item is clickable or disabled + checked (bool): Whether the menu item shows a checkmark (for checkbox items) + type (str): The menu item type ('button', 'checkbox', 'separator', 'submenu') + menu (TrayMenu): For submenu type items, contains the submenu items + """ + + cdef object __weakref__ + + cdef str __label + cdef object __callback + cdef bint __enabled + cdef bint __checked + cdef str __type + cdef TrayMenu __parent_menu + + def __init__(self, label='', callback=None, enabled=True, checked=False, + type='button', menu=None, **kwargs): + """ + Initializes a tray menu item. + + Args: + label (str): The text shown for this menu item + callback (callable): Function to call when the item is clicked + enabled (bool): Whether the item is enabled (can be clicked) + checked (bool): Whether the item shows a checkmark + type (str): The menu item type ('button', 'checkbox', 'separator', 'submenu') + menu (TrayMenu): For submenu types, contains submenu items + """ + self.__label = label + self.__callback = callback + self.__enabled = enabled + self.__checked = checked + self.__type = type + self.__parent_menu = menu + + cpdef void _on_pointer_set(self, uintptr_t value): + if value != 0: # If not NULL + _tray_menu_registry[value] = self + + @property + def label(self): + """The text shown on the menu item.""" + return self.__label + + @label.setter + def label(self, value): + if value != self.__label: + self.__label = value + if self._can_update(): + _tray_icon_handler.update_menu_item_label(self) + + @property + def enabled(self): + """Whether the menu item is clickable or disabled.""" + return self.__enabled + + @enabled.setter + def enabled(self, value): + if value != self.__enabled: + self.__enabled = value + if self._can_update(): + _tray_icon_handler.update_menu_item_enabled(self) + + @property + def checked(self): + """Whether the menu item shows a checkmark (for checkbox items).""" + return self.__checked + + @checked.setter + def checked(self, value): + if value != self.__checked: + self.__checked = value + if self._can_update(): + _tray_icon_handler.update_menu_item_checked(self) + + @property + def callback(self): + """Function to call when the item is clicked.""" + return self.__callback + + @callback.setter + def callback(self, value): + if value != self.__callback: + self.__callback = value + + @property + def type(self): + """The menu item type ('button', 'checkbox', 'separator', 'submenu').""" + return self.__type + + @property + def parent_menu(self): + """For submenu type items, contains the submenu items.""" + return self.__parent_menu + + +cdef class TrayMenu(_SDLPointerHandler): + """Represents a system tray menu or submenu.""" + + cdef list __items + + def __init__(self) -> None: + self.__items = [] + + cpdef list get_items(self): + return self.__items + + cpdef void add_item(self, TrayMenuItem item, index=-1): + """ + Add an item to the menu. + + Args: + item: TrayMenuItem or dict to add to the menu + index: Index where to insert the item (-1 to append to the end) + """ + if isinstance(item, dict): + # Handle submenu case + if 'submenu' in item: + submenu = TrayMenu(items=item.pop('submenu')) + item['menu'] = submenu + item['type'] = 'submenu' + item = TrayMenuItem(**item) + elif item == 'separator': + item = TrayMenuItem(type='separator') + + if not isinstance(item, TrayMenuItem): + Logger.warning(f"SystemTray: Cannot add invalid item type: {type(item)}") + return + + # If the menu already exists in SDL, add the item there too + if self._can_update(): + _tray_icon_handler.add_menu_item(self, item, index) + + if index < 0 or index >= len(self.__items): + self.__items.append(item) + else: + self.__items.insert(index, item) + + cpdef void remove_item(self, TrayMenuItem item): + """ + Remove an item from the menu. + + Args: + item: TrayMenuItem to remove + """ + if item in self.__items: + # If the menu exists in SDL, remove the item from there too + if self.has_valid_pointer() and item.has_valid_pointer() and _tray_icon_handler: + _tray_icon_handler.remove_menu_item(self, item) + + self.__items.remove(item) + + cpdef void clear(self): + """Remove all items from the menu.""" + # If the menu exists in SDL, remove all items from there too + cdef TrayMenuItem item + if self._can_update(): + for item in list(self.__items): + if item.has_valid_pointer(): + _tray_icon_handler.remove_menu_item(self, item) + + self.__items = [] + + +cdef class TrayIcon(_SDLPointerHandler): + """ + Manages a system tray icon with menu functionality. + + Properties: + icon_path (str): Path to the icon image + tooltip (str): Tooltip text shown when hovering over the tray icon + menu (TrayMenu): The menu displayed when clicking on the tray icon + visible (bool): Whether the tray icon is currently visible + """ + + cdef str __icon_path + cdef str __tooltip + cdef TrayMenu __menu + cdef bint __visible + + def __init__(self, icon_path='', tooltip='Kivy Application', menu=None, **kwargs): + """ + Initializes a system tray icon. + + Args: + icon_path (str): Path to the icon image file + tooltip (str): Text to be displayed when hovering over the icon + menu (TrayMenu or list): Menu to be displayed when clicking on the icon + """ + self.__icon_path = icon_path or resource_find('data/logo/kivy-icon-32.png') + self.tooltip = tooltip + + # Process the menu if provided + if menu: + if isinstance(menu, list): + self.__menu = TrayMenu(items=menu) + elif isinstance(menu, TrayMenu): + self.__menu = menu + else: + Logger.warning(f"TrayIcon: Invalid menu type: {type(menu)}") + self.__menu = TrayMenu() + else: + self.__menu = TrayMenu() + + cpdef bint create(self): + """Creates and displays the tray icon.""" + if self.__visible: + Logger.warning("TrayIcon: The tray icon is already visible") + return False + + global _tray_icon_handler + if _tray_icon_handler is None: + _tray_icon_handler = _TrayIconHandler() + + self.__visible = _tray_icon_handler.create_tray(self) + return self.__visible + + def destroy(self): + """Removes the tray icon from the system tray.""" + if not self.__visible: + Logger.warning("TrayIcon: The tray icon is not visible") + return False + + if _tray_icon_handler: + return _tray_icon_handler.destroy_tray(self) + return False + + @property + def icon(self): + return self.__icon_path + + @icon.setter + def icon(self, value): + """ + Updates the tray icon image. + + Args: + value (str): Path to the new icon image file + """ + self.__icon_path = value + if self.__visible and _tray_icon_handler: + _tray_icon_handler.update_tray(self, value) + + @property + def tooltip(self): + return self.__tooltip + + @tooltip.setter + def tooltip(self, value): + """ + Updates the tray icon tooltip. + + Args: + value (str): New tooltip text + """ + self.__tooltip = value + if self.__visible and _tray_icon_handler: + _tray_icon_handler.update_tray_tooltip(self, value) + + @property + def visible(self): + return self.__visible + + @visible.setter + def visible(self, value): + self.__visible = value + + @property + def menu(self): + return self.__menu + + def clear_menu(self): + """ + Removes all items from the tray icon's menu. + + Returns: + bool: True if successful, False otherwise + """ + if not self.__visible: + Logger.warning("TrayIcon: Cannot clear menu for non-visible tray icon") + return False + + if not self.__menu: + Logger.warning("TrayIcon: No menu exists to clear") + return False + + self.__menu.clear() + return True + + +# Global variable to store the handler instance +cdef _TrayIconHandler _tray_icon_handler + + +cdef class _TrayIconHandler: + """Internal class for managing SDL tray icon resources.""" + + cpdef bint create_tray(self, TrayIcon tray_icon): + """ + Creates a system tray icon. + + Args: + tray_icon: Python TrayIcon object + """ + # Load the icon image + cdef bytes icon_path_bytes = tray_icon.icon.encode('utf-8') + cdef bytes tooltip_bytes = tray_icon.tooltip.encode('utf-8') + + Logger.debug(f"TrayIcon: Loading icon from {tray_icon.icon}") + cdef SDL_Surface *icon_surface = IMG_Load(icon_path_bytes) + + if not icon_surface: + error = SDL_GetError() + Logger.error(f"TrayIcon: Failed to load icon: {error.decode('utf-8', 'replace')}") + return False + + # Create the tray icon + Logger.debug(f"TrayIcon: Creating tray with tooltip '{tray_icon.tooltip}'") + cdef SDL_Tray *tray = SDL_CreateTray(icon_surface, tooltip_bytes) + + if not tray: + error = SDL_GetError() + Logger.error(f"TrayIcon: Failed to create tray: {error.decode('utf-8', 'replace')}") + SDL_DestroySurface(icon_surface) + return False + + # Create and populate the menu if it exists + cdef SDL_TrayMenu *sdl_menu + cdef TrayMenu menu = tray_icon.menu + tray_icon_menu_items = menu.get_items() + if menu and tray_icon_menu_items: + sdl_menu = SDL_CreateTrayMenu(tray) + + if not sdl_menu: + error = SDL_GetError() + Logger.error(f"TrayIcon: Failed to create menu: {error.decode('utf-8', 'replace')}") + SDL_DestroyTray(tray) + SDL_DestroySurface(icon_surface) + return False + + # Store the SDL menu pointer in the Python menu object + menu.set_pointer(sdl_menu) + + # Populate the menu with items + self._populate_menu(sdl_menu, menu) + + Logger.info(f"TrayIcon: Successfully created tray icon") + + tray_icon.set_pointer(tray) + + return True + + cpdef destroy_tray(self, TrayIcon tray_icon): + """ + Destroys the tray icon and cleans up resources. + + Args: + tray_icon: Python TrayIcon object + """ + cdef SDL_Tray *tray + if tray_icon.has_valid_pointer(): + Logger.debug("TrayIcon: Destroying tray icon") + tray = tray_icon.get_pointer() + SDL_DestroyTray(tray) + tray_icon.visible = False + tray_icon.set_pointer(0) + return True + return False + + cpdef update_tray(self, TrayIcon tray_icon, str icon_path): + """ + Updates the tray icon image. + + Args: + tray_icon: Python TrayIcon object + icon_path: Path to the new icon image file + """ + if not tray_icon.has_valid_pointer(): + Logger.warning("TrayIcon: Cannot update icon for non-existent tray") + return False + + # Load the new icon image + cdef bytes icon_path_bytes = icon_path.encode('utf-8') + cdef SDL_Surface *new_icon_surface = IMG_Load(icon_path_bytes) + + if not new_icon_surface: + error = SDL_GetError() + Logger.error(f"TrayIcon: Failed to load new icon: {error.decode('utf-8', 'replace')}") + return False + + # Update the tray icon + cdef SDL_Tray *tray = tray_icon.get_pointer() + SDL_SetTrayIcon(tray, new_icon_surface) + + # Check for errors after the call + error = SDL_GetError() + if error: + Logger.error(f"TrayIcon: Failed to update icon: {error.decode('utf-8', 'replace')}") + SDL_DestroySurface(new_icon_surface) + return False + + # SDL_SetTrayIcon takes ownership of the surface, we don't need to destroy it + return True + + cpdef update_tray_tooltip(self, TrayIcon tray_icon, str tooltip): + """ + Updates the tray icon tooltip. + + Args: + tray_icon: Python TrayIcon object + tooltip: New tooltip text + """ + if not tray_icon.has_valid_pointer(): + Logger.warning("TrayIcon: Cannot update tooltip for non-existent tray") + return False + + cdef bytes tooltip_bytes = tooltip.encode('utf-8') + cdef SDL_Tray *tray = tray_icon.get_pointer() + + SDL_SetTrayTooltip(tray, tooltip_bytes) + + # Check for errors after the call + error = SDL_GetError() + if error: + Logger.error(f"TrayIcon: Failed to update tooltip: {error.decode('utf-8', 'replace')}") + return False + + return True + + cdef add_menu_item(self, TrayMenu menu, TrayMenuItem item, index=-1): + """ + Adds a menu item to an existing menu. + + Args: + menu: Python TrayMenu object + item: Python TrayMenuItem object + index: Index where to insert the item (-1 to append at the end) + """ + if not menu.has_valid_pointer(): + return False + + cdef SDL_TrayMenu *sdl_menu = menu.get_pointer() + cdef SDL_TrayEntry *entry + cdef bytes label_bytes + cdef int entry_type + cdef TrayMenu parent_menu = item.parent_menu + + # Determine entry type + if item.type == 'checkbox': + entry_type = SDL_TRAYENTRY_CHECKBOX + elif item.type == 'submenu': + entry_type = SDL_TRAYENTRY_SUBMENU + else: # Default to button + entry_type = SDL_TRAYENTRY_BUTTON + + # Create the entry + if item.type != 'separator': + label_bytes = item.label.encode('utf-8') + entry = SDL_InsertTrayEntryAt(sdl_menu, index, label_bytes, entry_type) + else: + entry = SDL_InsertTrayEntryAt(sdl_menu, index, NULL, entry_type) + + if not entry: + error = SDL_GetError() + Logger.warning(f"TrayIcon: Failed to add menu item: {error.decode('utf-8', 'replace')}") + return False + + # Register the menu item with the entry + item.set_pointer(entry) + + # Set callback for clickable items + if item.type in ('button', 'checkbox'): + SDL_SetTrayEntryCallback(entry, _tray_item_callback, NULL) + + # Set initial enabled state + if not item.enabled: + SDL_SetTrayEntryEnabled(entry, 0) + + # Set initial checked state for checkboxes + if item.type == 'checkbox' and item.checked: + SDL_SetTrayEntryChecked(entry, 1) + + # Create submenu if needed + cdef SDL_TrayMenu *sdl_submenu + if item.type == 'submenu' and parent_menu: + sdl_submenu = SDL_CreateTraySubmenu(entry) + + if not sdl_submenu: + error = SDL_GetError() + Logger.error(f"TrayIcon: Failed to create submenu: {error.decode('utf-8', 'replace')}") + return False + + # Store the SDL submenu pointer in the Python submenu object + parent_menu.set_pointer(sdl_submenu) + + # Populate the submenu with items + self._populate_menu(sdl_submenu, parent_menu) + + return True + + cpdef remove_menu_item(self, TrayMenu menu, TrayMenuItem item): + """ + Removes a menu item from an existing menu. + + Args: + menu: Python TrayMenu object + item: Python TrayMenuItem object + """ + if not menu.has_valid_pointer() or not item.has_valid_pointer(): + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + + SDL_RemoveTrayEntry(entry) + item.set_pointer(0) + + return True + + cpdef update_menu_item_label(self, TrayMenuItem item): + """ + Updates a menu item's label. + + Args: + item: Python TrayMenuItem object + """ + if not item.has_valid_pointer(): + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + cdef bytes label_bytes = item.label.encode('utf-8') + + SDL_SetTrayEntryLabel(entry, label_bytes) + + return True + + cpdef update_menu_item_enabled(self, TrayMenuItem item): + """ + Updates a menu item's enabled state. + + Args: + item: Python TrayMenuItem object + """ + if not item.has_valid_pointer(): + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + + SDL_SetTrayEntryEnabled(entry, 1 if item.enabled else 0) + + return True + + cpdef update_menu_item_checked(self, TrayMenuItem item): + """ + Updates a menu item's checked state. + + Args: + item: Python TrayMenuItem object + """ + if not item.has_valid_pointer() or item.type != 'checkbox': + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + + SDL_SetTrayEntryChecked(entry, 1 if item.checked else 0) + + return True + + cdef _populate_menu(self, SDL_TrayMenu *sdl_menu, TrayMenu menu): + """ + Populates a menu with items. + + Args: + sdl_menu: SDL_TrayMenu pointer + menu: Python TrayMenu object + """ + cdef SDL_TrayEntry *entry + cdef bytes label_bytes + cdef int entry_type + + cdef SDL_TrayMenu *sdl_submenu + cdef TrayMenuItem item + cdef TrayMenu parent_menu + + menu_items = menu.get_items() + + for item in menu_items: + # Skip items that already have entries + if item.has_valid_pointer(): + continue + + parent_menu = item.parent_menu + + # Determine entry type + if item.type == 'checkbox': + entry_type = SDL_TRAYENTRY_CHECKBOX + elif item.type == 'submenu': + entry_type = SDL_TRAYENTRY_SUBMENU + else: # Default to button + entry_type = SDL_TRAYENTRY_BUTTON + + # Create the entry + if item.type != 'separator': + label_bytes = item.label.encode('utf-8') + entry = SDL_InsertTrayEntryAt(sdl_menu, -1, label_bytes, entry_type) + else: + entry = SDL_InsertTrayEntryAt(sdl_menu, -1, NULL, entry_type) + + if not entry: + error = SDL_GetError() + Logger.warning(f"TrayIcon: Failed to create menu item: {error.decode('utf-8', 'replace')}") + continue + + # Register the menu item with the entry + item.set_pointer(entry) + + # Set callback for clickable items + if item.type in ('button', 'checkbox'): + SDL_SetTrayEntryCallback(entry, _tray_item_callback, NULL) + + # Set initial enabled state + if not item.enabled: + SDL_SetTrayEntryEnabled(entry, 0) + + # Set initial checked state for checkboxes + if item.type == 'checkbox' and item.checked: + SDL_SetTrayEntryChecked(entry, 1) + + # Create submenu if needed + if item.type == 'submenu' and parent_menu: + sdl_submenu = SDL_CreateTraySubmenu(entry) + + if not sdl_submenu: + error = SDL_GetError() + Logger.error(f"TrayIcon: Failed to create submenu: {error.decode('utf-8', 'replace')}") + continue + + # Store the SDL submenu pointer in the Python submenu object + parent_menu.set_pointer(sdl_submenu) + + # Populate the submenu with items + self._populate_menu(sdl_submenu, parent_menu) diff --git a/kivy/lib/sdl3.pxi b/kivy/lib/sdl3.pxi index 9857783141..b4bd8d46cc 100644 --- a/kivy/lib/sdl3.pxi +++ b/kivy/lib/sdl3.pxi @@ -13,6 +13,8 @@ cdef extern from "SDL_joystick.h": cdef int SDL_HAT_DOWN = 0x04 cdef int SDL_HAT_LEFT = 0x08 + + cdef extern from "SDL.h": ctypedef unsigned char Uint8 ctypedef unsigned long Uint32 @@ -246,6 +248,7 @@ cdef extern from "SDL.h": SDL_WINDOW_UTILITY SDL_WINDOW_TRANSPARENT SDL_WINDOW_METAL = 0x20000000 #, /**< window usable for Metal view */ + SDL_WINDOW_MODAL = 0x0000000000001000 ctypedef enum SDL_HitTestResult: SDL_HITTEST_NORMAL @@ -592,6 +595,7 @@ cdef extern from "SDL.h": cdef int SDL_GetWindowDisplayIndex(SDL_Window * window) cdef Uint32 SDL_GetWindowPixelFormat(SDL_Window * window) cdef SDL_Window * SDL_CreateWindowFrom(const void *data) + cdef int SDL_SetWindowParent(SDL_Window *window, SDL_Window *parent) cdef Uint32 SDL_GetWindowID(SDL_Window * window) cdef SDL_Window * SDL_GetWindowFromID(Uint32 id) cdef Uint32 SDL_GetWindowFlags(SDL_Window * window) @@ -640,6 +644,8 @@ cdef extern from "SDL.h": cdef void SDL_GL_SwapWindow(SDL_Window * window) nogil cdef int SDL_GL_DestroyContext(SDL_GLContext context) + + cdef void SDL_GetJoysticks(int *numjoysticks) cdef SDL_Joystick * SDL_OpenJoystick(int index) cdef SDL_Window * SDL_GetKeyboardFocus() @@ -690,6 +696,55 @@ cdef extern from "SDL.h": Uint16 SDL_LIL_ENDIAN Uint16 SDL_BIG_ENDIAN + +# cdef extern from "SDL_begin_code.h": + +# cdef SDLCALL __cdecl + + +cdef extern from "SDL_tray.h": + ctypedef struct SDL_Tray + ctypedef struct SDL_TrayMenu + ctypedef struct SDL_TrayEntry + + ctypedef Uint32 SDL_TrayEntryFlags + + # Constants + cdef Uint32 SDL_TRAYENTRY_BUTTON # Make the entry a simple button. Required. + cdef Uint32 SDL_TRAYENTRY_CHECKBOX # Make the entry a checkbox. Required. + cdef Uint32 SDL_TRAYENTRY_SUBMENU # Prepare the entry to have a submenu. Required + cdef Uint32 SDL_TRAYENTRY_DISABLED # Make the entry disabled. Optional. + cdef Uint32 SDL_TRAYENTRY_CHECKED # Make the entry checked. This is valid only for checkboxes. Optional. + + ctypedef void (*SDL_TrayCallback)(void *userdata, SDL_TrayEntry *entry) + + # Functions + cdef void SDL_ClickTrayEntry(SDL_TrayEntry *entry) nogil + cdef SDL_Tray* SDL_CreateTray(SDL_Surface *icon, const char *tooltip) nogil + cdef SDL_TrayMenu* SDL_CreateTrayMenu(SDL_Tray *tray) nogil + cdef SDL_TrayMenu* SDL_CreateTraySubmenu(SDL_TrayEntry *entry) nogil + cdef void SDL_DestroyTray(SDL_Tray *tray) nogil + cdef const SDL_TrayEntry** SDL_GetTrayEntries(SDL_TrayMenu *menu, int *count) nogil + cdef bint SDL_GetTrayEntryChecked(SDL_TrayEntry *entry) nogil + cdef bint SDL_GetTrayEntryEnabled(SDL_TrayEntry *entry) nogil + cdef const char* SDL_GetTrayEntryLabel(SDL_TrayEntry *entry) nogil + cdef SDL_TrayMenu* SDL_GetTrayEntryParent(SDL_TrayEntry *entry) nogil + cdef SDL_TrayMenu* SDL_GetTrayMenu(SDL_Tray *tray) nogil + cdef SDL_TrayEntry* SDL_GetTrayMenuParentEntry(SDL_TrayMenu *menu) nogil + cdef SDL_Tray* SDL_GetTrayMenuParentTray(SDL_TrayMenu *menu) nogil + cdef SDL_TrayMenu* SDL_GetTraySubmenu(SDL_TrayEntry *entry) nogil + cdef SDL_TrayEntry* SDL_InsertTrayEntryAt(SDL_TrayMenu *menu, int pos, const char *label, SDL_TrayEntryFlags flags) nogil + cdef void SDL_RemoveTrayEntry(SDL_TrayEntry *entry) nogil + cdef void SDL_SetTrayEntryCallback(SDL_TrayEntry *entry, SDL_TrayCallback callback, void *userdata) nogil + cdef void SDL_SetTrayEntryChecked(SDL_TrayEntry *entry, bint checked) nogil + cdef void SDL_SetTrayEntryEnabled(SDL_TrayEntry *entry, bint enabled) nogil + cdef void SDL_SetTrayEntryLabel(SDL_TrayEntry *entry, const char *label) nogil + cdef void SDL_SetTrayIcon(SDL_Tray *tray, SDL_Surface *icon) nogil + cdef void SDL_SetTrayTooltip(SDL_Tray *tray, const char *tooltip) nogil + cdef void SDL_UpdateTrays() nogil + + + cdef extern from "SDL_image.h": cdef SDL_Surface *IMG_Load(char *file) cdef SDL_Surface *IMG_Load_IO(SDL_IOStream *src, int freesrc) @@ -872,6 +927,7 @@ cdef extern from "SDL_audio.h": cdef int SDL_ConvertAudioSamples(const SDL_AudioSpec *src_spec, const Uint8 *src_data, int src_len, const SDL_AudioSpec *dst_spec, Uint8 **dst_data, int *dst_len) + cdef extern from "SDL_video.h": cdef int SDL_SetWindowOpacity(SDL_Window *window, float opacity) cdef float SDL_GetWindowOpacity(SDL_Window *window) @@ -884,6 +940,7 @@ cdef extern from "SDL_video.h": SDL_SYSTEM_THEME_DARK # /**< Dark colored system theme */ SDL_SystemTheme SDL_GetSystemTheme() nogil + cdef extern from "SDL_mixer.h": cdef struct Mix_Chunk: int allocated diff --git a/setup.py b/setup.py index 944b830c07..943c93bb68 100644 --- a/setup.py +++ b/setup.py @@ -984,6 +984,7 @@ def determine_sdl3(): _extra_args_cpp = {} for source_file in ('core/window/_window_sdl3.pyx', 'core/text/_text_sdl3.pyx', + 'core/tray/_tray_sdl3.pyx', 'core/audio_output/audio_sdl3.pyx', 'core/clipboard/_clipboard_sdl3.pyx'): From 66a07f0e15d1104d2da5ffa17751509136034ff1 Mon Sep 17 00:00:00 2001 From: Dexer <73297572+DexerBR@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:02:34 -0300 Subject: [PATCH 2/5] wip --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 515ac266b8..e2e866a4c4 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ kivy/setupconfig.py kivy/core/clipboard/_clipboard_sdl3.c kivy/graphics/egl_backend/egl_angle.c kivy/core/tray/_tray_sdl3.c +kivy/core/image/_img_sdl3.cpp From 07da96242f14648fa0b46bfc1fde86a0dd4e906c Mon Sep 17 00:00:00 2001 From: Dexer <73297572+DexerBR@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:04:55 -0300 Subject: [PATCH 3/5] wip --- .gitignore | 1 + examples/system_tray/system_tray_showcase.py | 2 +- kivy/core/system_tray/__init__.py | 2 + kivy/core/system_tray/_system_tray_sdl3.pyx | 1537 ++++++++++++++++++ kivy/core/tray/__init__.py | 2 - kivy/core/tray/_tray_sdl3.pyx | 732 --------- kivy/core/window/window_sdl3.py | 4 +- kivy/tests/test_system_tray.py | 572 +++++++ setup.py | 2 +- 9 files changed, 2116 insertions(+), 738 deletions(-) create mode 100644 kivy/core/system_tray/__init__.py create mode 100644 kivy/core/system_tray/_system_tray_sdl3.pyx delete mode 100644 kivy/core/tray/__init__.py delete mode 100644 kivy/core/tray/_tray_sdl3.pyx create mode 100644 kivy/tests/test_system_tray.py diff --git a/.gitignore b/.gitignore index e2e866a4c4..380540b3ae 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ kivy/core/clipboard/_clipboard_sdl3.c kivy/graphics/egl_backend/egl_angle.c kivy/core/tray/_tray_sdl3.c kivy/core/image/_img_sdl3.cpp +kivy/core/system_tray/_system_tray_sdl3.c diff --git a/examples/system_tray/system_tray_showcase.py b/examples/system_tray/system_tray_showcase.py index abda3013d9..13c85930cc 100644 --- a/examples/system_tray/system_tray_showcase.py +++ b/examples/system_tray/system_tray_showcase.py @@ -1,6 +1,6 @@ import os from kivy.app import App -from kivy.core.tray import TrayIcon, TrayMenu, TrayMenuItem +from kivy.core.system_tray import TrayIcon, TrayMenu, TrayMenuItem from kivy.lang import Builder from kivy.logger import Logger from kivy.properties import ObjectProperty diff --git a/kivy/core/system_tray/__init__.py b/kivy/core/system_tray/__init__.py new file mode 100644 index 0000000000..51a58d6627 --- /dev/null +++ b/kivy/core/system_tray/__init__.py @@ -0,0 +1,2 @@ +from kivy.core.system_tray._system_tray_sdl3 import * + diff --git a/kivy/core/system_tray/_system_tray_sdl3.pyx b/kivy/core/system_tray/_system_tray_sdl3.pyx new file mode 100644 index 0000000000..1ce97fe0a0 --- /dev/null +++ b/kivy/core/system_tray/_system_tray_sdl3.pyx @@ -0,0 +1,1537 @@ +include "../../../kivy/lib/sdl3.pxi" + +import os +import weakref +import platform +from os import environ +from kivy.config import Config +from kivy.logger import Logger +from kivy.graphics.cgl cimport * +from libc.stdint cimport uintptr_t +from kivy.logger import Logger +from kivy.resources import resource_find +from libc.stdint cimport uintptr_t + + + +# Global dictionary to track system tray menu items +# Stores weak references to prevent memory leaks +_tray_menu_registry = weakref.WeakValueDictionary() + +# Global variable to store the handler instance +cdef _TrayIconHandler _tray_icon_handler + + + +cdef void _tray_item_callback(void *userdata, SDL_TrayEntry *entry) nogil: + """ + C callback function invoked when a tray menu item is clicked. + + Args: + userdata: Pointer containing user data (not used) + entry: SDL_TrayEntry that was clicked + """ + cdef uintptr_t addr = entry + + with gil: + # Find the menu item for this entry + menu_item = _tray_menu_registry.get(addr) + + if menu_item is None: + Logger.warning(f"SystemTray: No menu item found for pointer {entry}") + return + + # For checkbox items, toggle the checked state + if menu_item.type == 'checkbox': + menu_item.checked = not menu_item.checked + + # Call the menu item callback if it exists + if menu_item.callback: + try: + menu_item.callback(menu_item) + except Exception as e: + Logger.error(f"SystemTray: Exception in callback: {e}") + + +cdef class _SDLPointerHandler: + + cdef uintptr_t __pointer + + def __cinit__(self): + self.__pointer = 0 # Initialized as NULL + + cdef uintptr_t get_pointer(self): + """Gets the SDL pointer associated with this item.""" + return self.__pointer + + cdef void set_pointer(self, uintptr_t value): + """Sets the SDL pointer associated with this item.""" + if value < 0: + raise ValueError("Invalid pointer value") + self.__pointer = value + self._on_pointer_set(value) + + cdef void _on_pointer_set(self, uintptr_t value): + pass + + cdef bint has_valid_pointer(self): + """Returns whether this object has a valid SDL pointer.""" + return self.__pointer != 0 + + cpdef bint _can_update(self): + """Returns whether this object can be updated via the tray icon handler.""" + global _tray_icon_handler + return self.has_valid_pointer() and _tray_icon_handler is not None + + +cdef class TrayMenuItem(_SDLPointerHandler): + """ + Represents a single item in a system tray menu. + + A TrayMenuItem can be one of four types: + + * **button**: Standard clickable menu item with callback + * **checkbox**: Toggleable item that shows checked/unchecked state + * **separator**: Visual divider between menu sections (no interaction) + * **submenu**: Item that opens another menu when hovered/clicked + + Examples: + Create a basic button item:: + + def on_exit_click(): + print("Exit clicked!") + + exit_item = TrayMenuItem( + label="Exit", + type="button", + callback=on_exit_click + ) + + Create a checkbox item:: + + def on_notifications_toggle(): + print(f"Notifications: {item.checked}") + + notifications_item = TrayMenuItem( + label="Enable Notifications", + type="checkbox", + callback=on_notifications_toggle, + checked=True + ) + + Create a submenu:: + + sub_menu = TrayMenu() + sub_menu.add_item(TrayMenuItem("Option 1", callback=option1_handler)) + + submenu_item = TrayMenuItem( + label="Settings", + type="submenu", + menu=sub_menu + ) + + Create a separator:: + + separator = TrayMenuItem(type="separator") + + Note: + Type-specific constraints are automatically validated: + + * 'separator' and 'submenu' items cannot have callbacks or be checked + * 'submenu' items must provide a menu parameter + * 'button' items cannot be checked + * Only 'submenu' items can have submenus + + Attributes: + label (str): Text displayed for this menu item + type (str): Item type - one of 'button', 'checkbox', 'separator', 'submenu' + callback (callable): Function called when item is clicked (None for separators/submenus) + enabled (bool): Whether item can be interacted with + checked (bool): Show checkmark (checkbox type only) + sub_menu (TrayMenu): Submenu to display (submenu type only) + """ + + cdef object __weakref__ + + cdef str __label + cdef object __callback + cdef bint __enabled + cdef bint __checked + cdef str __type + cdef TrayMenu __sub_menu + + def __init__(self, label='', type='button', callback=None, enabled=True, checked=False, menu=None, **kwargs): + """ + Initialize a tray menu item. + + Args: + label (str, optional): Text displayed for this menu item. Defaults to ''. + type (str, optional): Item type. Must be one of 'button', 'checkbox', + 'separator', or 'submenu'. Defaults to 'button'. + callback (callable, optional): Function called when item is clicked. + Cannot be used with 'separator' or 'submenu' types. Defaults to None. + enabled (bool, optional): Whether item can be interacted with. + Defaults to True. + checked (bool, optional): Show checkmark for 'checkbox' type items only. + Defaults to False. + menu (TrayMenu, optional): Submenu to display. Required for 'submenu' type, + forbidden for other types. Defaults to None. + **kwargs: Additional arguments passed to parent class. + + Raises: + ValueError: If type is invalid or type-specific constraints are violated: + + * Invalid type (not in 'button', 'checkbox', 'separator', 'submenu') + * 'separator' or 'submenu' items with callback or checked=True + * 'submenu' items without a menu parameter + * Non-'submenu' items with a menu parameter + * 'button' items with checked=True + + Examples: + >>> # Basic button + >>> item = TrayMenuItem("Open", callback=lambda: print("opened")) + + >>> # Checkbox item + >>> toggle = TrayMenuItem("Auto-start", type="checkbox", checked=True) + + >>> # Separator + >>> sep = TrayMenuItem(type="separator") + + >>> # Submenu + >>> submenu = TrayMenuItem("Options", type="submenu", menu=my_menu) + """ + self._validate_type_constraints(type, callback, checked, menu) + + self.__label = label + self.__callback = callback + self.__enabled = enabled + self.__checked = checked + self.__type = type + self.__sub_menu = menu + + cdef void _validate_type_constraints(self, str type, object callback, bint checked, object menu): + """ + Validate type and type-specific parameter constraints. + + Args: + type (str): The menu item type to validate + callback (object): The callback function (if any) + checked (bint): Whether the item should be checked + menu (object): The submenu (if any) + + Raises: + ValueError: If validation fails for any constraint + """ + # Validate type + valid_types = {'button', 'checkbox', 'separator', 'submenu'} + if type not in valid_types: + raise ValueError(f"Invalid type '{type}'. Must be one of: {valid_types}") + + # Type-specific validations + if type in ('separator', 'submenu'): + if callback is not None: + raise ValueError(f"'{type}' items cannot have callbacks.") + if checked: + raise ValueError(f"'{type}' items cannot be checked.") + + if type == 'submenu' and menu is None: + raise ValueError(f"'{type}' items must provide a menu to display.") + + if type in ('checkbox', 'button', 'separator') and menu is not None: + raise ValueError(f"'{type}' items cannot have submenus.") + + if type == 'button' and checked: + raise ValueError(f"'{type}' items cannot be checked") + + cpdef void _on_pointer_set(self, uintptr_t value): + """ + Internal method called when the native pointer is set. + + Args: + value (uintptr_t): The pointer value to register + """ + if value != 0: # If not NULL + _tray_menu_registry[value] = self + + @property + def label(self): + """ + str: The text shown on the menu item. + + Setting this property will immediately update the visual representation + if the menu is currently displayed. + + Examples: + >>> item = TrayMenuItem("Original") + >>> item.label = "Updated" # Menu updates automatically + """ + return self.__label + + @label.setter + def label(self, value): + if value != self.__label: + self.__label = value + if self._can_update(): + _tray_icon_handler.update_menu_item_label(self) + + @property + def enabled(self): + """ + bool: Whether the menu item is clickable or disabled. + + Disabled items appear grayed out (disabled) and cannot be clicked. + Setting this property will immediately update the visual state. + + Examples: + >>> item.enabled = False # Item becomes disabled + >>> item.enabled = True # Item becomes clickable again + """ + return self.__enabled + + @enabled.setter + def enabled(self, value): + if value != self.__enabled: + self.__enabled = value + if self._can_update(): + _tray_icon_handler.update_menu_item_enabled(self) + + @property + def checked(self): + """ + bool: Whether the menu item shows a checkmark (checkbox type only). + + Only meaningful for 'checkbox' type items. Setting this on other + types has no visual effect but the value is stored. + + Examples: + >>> checkbox_item = TrayMenuItem("Option", type="checkbox") + >>> checkbox_item.checked = True # Shows checkmark + >>> checkbox_item.checked = False # Removes checkmark + """ + return self.__checked + + @checked.setter + def checked(self, value): + if value != self.__checked: + self.__checked = value + if self._can_update(): + _tray_icon_handler.update_menu_item_checked(self) + + @property + def callback(self): + """ + callable or None: Function to call when the item is clicked. + + The callback function should accept no parameters. For 'separator' + and 'submenu' type items, this is always None. + + Examples: + >>> def my_handler(): + ... print("Item clicked!") + >>> item.callback = my_handler + """ + return self.__callback + + @callback.setter + def callback(self, value): + if value != self.__callback: + self.__callback = value + + @property + def type(self): + """ + str: The menu item type. + + Read-only property that returns one of: + + * 'button' - Standard clickable item + * 'checkbox' - Toggleable item with checkmark + * 'separator' - Visual divider (no interaction) + * 'submenu' - Opens another menu + + The type is set during initialization and cannot be changed. + """ + return self.__type + + @property + def sub_menu(self): + """ + TrayMenu or None: The submenu displayed for 'submenu' type items. + + Only 'submenu' type items can have a submenu. For other types, + this is always None. + + Examples: + >>> if item.type == 'submenu': + ... submenu = item.sub_menu + ... submenu.add_item(new_item) + """ + return self.__sub_menu + + +cdef class TrayMenu(_SDLPointerHandler): + """ + Represents a system tray menu or submenu container. + + A TrayMenu is a collection of :class:`TrayMenuItem` objects that can be displayed + as a context menu when the system tray icon is clicked, or as a submenu when + a submenu item is hovered/clicked. + + The menu supports dynamic modification - items can be added, removed, or cleared + at runtime, and changes will be reflected immediately in the displayed menu + if it's currently active. + + Examples: + Create a basic menu with different item types:: + + # Create the menu + menu = TrayMenu() + + # Add a button item + menu.add_item(TrayMenuItem( + label="Open Application", + callback=lambda: print("Opening...") + )) + + # Add a checkbox item + menu.add_item(TrayMenuItem( + label="Auto-start", + type="checkbox", + checked=True, + callback=lambda: print("Toggled auto-start") + )) + + # Add a separator + menu.add_item(TrayMenuItem(type="separator")) + + # Add an exit button + menu.add_item(TrayMenuItem( + label="Exit", + callback=lambda: app.quit() + )) + + Create a menu with submenus:: + + # Create main menu + main_menu = TrayMenu() + + # Create submenu + settings_menu = TrayMenu() + settings_menu.add_item(TrayMenuItem("Preferences", callback=show_prefs)) + settings_menu.add_item(TrayMenuItem("About", callback=show_about)) + + # Add submenu to main menu + main_menu.add_item(TrayMenuItem( + label="Settings", + type="submenu", + menu=settings_menu + )) + + Note: + * Menu changes are applied immediately to active menus + * Only :class:`TrayMenuItem` objects can be added directly + + Attributes: + items (list): Read-only list of :class:`TrayMenuItem` objects in this menu + """ + + cdef list __items + + def __init__(self) -> None: + """ + Initialize an empty tray menu. + + The menu starts with no items. Use :meth:`add_item` to populate it. + + Examples: + >>> menu = TrayMenu() + >>> print(len(menu.get_items())) # 0 + """ + self.__items = [] + + cpdef list get_items(self): + """ + Get all items in this menu. + + Returns: + list: A list of :class:`TrayMenuItem` objects in display order. + + Note: + The returned list is the actual internal list. Modifying it directly + may cause inconsistencies. Use :meth:`add_item`, :meth:`remove_item`, + and :meth:`clear` instead. + + Examples: + >>> menu = TrayMenu() + >>> menu.add_item(TrayMenuItem("Test")) + >>> items = menu.get_items() + >>> print(len(items)) # 1 + >>> print(items[0].label) # "Test" + """ + return self.__items + + cpdef void add_item(self, TrayMenuItem item, index=-1): + """ + Add an item to the menu at the specified position. + + Args: + item (TrayMenuItem): Item to add to the menu. Must be a + :class:`TrayMenuItem` object. + index (int, optional): Position where to insert the item. + Use -1 to append to the end. Defaults to -1. + + Raises: + ValueError: If item is not a TrayMenuItem instance. + + Examples: + Add TrayMenuItem objects:: + + menu = TrayMenu() + item = TrayMenuItem("Click me", callback=handler) + menu.add_item(item) # Appends to end + menu.add_item(item2, index=0) # Inserts at beginning + + Note: + If the menu is currently displayed, the item is added to the + native menu representation immediately. + """ + if not isinstance(item, TrayMenuItem): + raise ValueError(f"Cannot add invalid item type: {type(item)}, it should be an instance of TrayMenuItem") + + # If the menu already exists in SDL, add the item there too + if self._can_update(): + _tray_icon_handler.add_menu_item(self, item, index) + + if index < 0 or index >= len(self.__items): + self.__items.append(item) + else: + self.__items.insert(index, item) + + cpdef void remove_item(self, TrayMenuItem item): + """ + Remove a specific item from the menu. + + Args: + item (TrayMenuItem): The exact TrayMenuItem object to remove. + + Note: + This method removes the first occurrence of the item if it exists + in the menu. If the item is not found, no action is taken. + + If the menu is currently displayed, the item is also removed + from the native menu representation immediately. + + Examples: + >>> menu = TrayMenu() + >>> item1 = TrayMenuItem("Item 1") + >>> item2 = TrayMenuItem("Item 2") + >>> menu.add_item(item1) + >>> menu.add_item(item2) + >>> menu.remove_item(item1) # Only item2 remains + >>> print(len(menu.get_items())) # 1 + + Warning: + The item parameter must be the exact same TrayMenuItem object + that was added to the menu. Creating a new TrayMenuItem with + the same properties will not match for removal. + """ + if item in self.__items: + # If the menu exists in SDL, remove the item from there too + if self.has_valid_pointer() and item.has_valid_pointer() and _tray_icon_handler: + _tray_icon_handler.remove_menu_item(self, item) + + self.__items.remove(item) + + cpdef void clear(self): + """ + Remove all items from the menu. + + This method empties the menu completely. If the menu is currently + displayed, all items are removed from the native menu representation + immediately. + + Examples: + >>> menu = TrayMenu() + >>> menu.add_item(TrayMenuItem("Item 1")) + >>> menu.add_item(TrayMenuItem("Item 2")) + >>> print(len(menu.get_items())) # 2 + >>> menu.clear() + >>> print(len(menu.get_items())) # 0 + + Note: + This is more efficient than calling :meth:`remove_item` for each + item individually when you need to empty the entire menu. + """ + # If the menu exists in SDL, remove all items from there too + cdef TrayMenuItem item + if self._can_update(): + for item in list(self.__items): + if item.has_valid_pointer(): + _tray_icon_handler.remove_menu_item(self, item) + + self.__items = [] + + +cdef class TrayIcon(_SDLPointerHandler): + """ + Manages a system tray icon with context menu functionality. + + A TrayIcon provides integration with the operating system's notification area + (system tray), allowing applications to display an icon that users can interact + with via clicking. The icon supports dynamic tooltip text and a customizable + context menu built from :class:`TrayMenu` objects. + + The tray icon remains visible in the system tray until explicitly destroyed, + and all properties (icon image, tooltip, menu) can be updated dynamically + while the icon is active. + + Examples: + Create a basic tray icon:: + + # Simple tray icon with default settings + tray = TrayIcon() + tray.create() + + Create a tray icon with custom properties:: + + # Create menu first + menu = TrayMenu() + menu.add_item(TrayMenuItem("Show Window", callback=show_main_window)) + menu.add_item(TrayMenuItem("Exit", callback=quit_app)) + + # Create tray icon with custom icon and menu + tray = TrayIcon( + icon_path="/path/to/icon.png", + tooltip="My Application", + menu=menu + ) + tray.create() + + Update tray icon properties dynamically:: + + # Change icon image + tray.icon = "/path/to/new_icon.png" + + # Update tooltip + tray.tooltip = "Application - Status: Connected" + + # Modify menu + tray.menu.add_item(TrayMenuItem("New Option", callback=handler)) + + Create tray icon with menu from list (legacy support):: + + # Using list of menu items (converted to TrayMenu automatically) + menu_items = [ + TrayMenuItem("Option 1", callback=option1_handler), + TrayMenuItem("Option 2", callback=option2_handler) + ] + tray = TrayIcon(menu=menu_items) + tray.create() + + Note: + * The tray icon must be created with :meth:`create` before becoming visible + * All property updates are applied immediately to the active tray icon + * The icon will use a default Kivy icon if no custom icon path is provided + + Attributes: + icon_path (str): Path to the icon image file displayed in system tray + tooltip (str): Text shown when hovering over the tray icon + menu (TrayMenu): The context menu displayed when clicking the tray icon + visible (bool): Read-only property indicating if the tray icon is currently active + """ + + cdef str __icon_path + cdef str __tooltip + cdef TrayMenu __menu + cdef bint __visible + cdef SDL_Surface* __current_icon_surface # reference to SDL surface attached to the current icon + + def __init__(self, icon_path='', tooltip='Kivy Application', menu=None, **kwargs): + """ + Initialize a system tray icon. + + Args: + icon_path (str, optional): Path to the icon image file. If empty, + uses the default Kivy icon. Defaults to ''. + tooltip (str, optional): Text displayed when hovering over the + tray icon. Defaults to 'Kivy Application'. + menu (TrayMenu, optional): Context menu to display when right-clicking + the tray icon. If None, creates an empty TrayMenu that can be + modified later via the menu property. Defaults to None. + **kwargs: Additional arguments passed to parent class. + + Note: + * The tray icon is not visible until :meth:`create` is called + * Invalid menu types will generate a warning and create empty menu + * If no icon_path is provided, the default Kivy logo will be used + + Examples: + >>> # Basic tray icon + >>> tray = TrayIcon() + + >>> # Custom icon and tooltip + >>> tray = TrayIcon( + ... icon_path="/app/icon.png", + ... tooltip="My App v1.0" + ... ) + + >>> # With predefined menu + >>> menu = TrayMenu() + >>> menu.add_item(TrayMenuItem("Exit", callback=quit)) + >>> tray = TrayIcon(menu=menu) + """ + self.__icon_path = icon_path or resource_find('data/logo/kivy-icon-32.png') + self.tooltip = tooltip + + # Process the menu if provided + if menu: + if isinstance(menu, TrayMenu): + self.__menu = menu + else: + raise ValueError(f"SystemTray: Invalid menu type: {type(menu)}") + else: + self.__menu = TrayMenu() + + cpdef bint create(self): + """ + Create and display the tray icon in the system tray. + + This method makes the tray icon visible and functional in the operating + system's notification area. The icon will respond to user interactions + and display the configured tooltip and context menu. + + Returns: + bool: True if the tray icon was created successfully, False otherwise. + + Note: + * Can only be called once per TrayIcon instance + * If already visible, logs a warning and returns False + * Initializes the global tray handler if not already initialized + + Examples: + >>> tray = TrayIcon() + >>> success = tray.create() + >>> if success: + ... print("Tray icon is now visible") + + Warning: + Calling this method on an already visible tray icon will log + a warning and return False without creating a duplicate icon. + """ + if self.__visible: + Logger.warning("SystemTray: The tray icon is already visible") + return False + + global _tray_icon_handler + if _tray_icon_handler is None: + _tray_icon_handler = _TrayIconHandler() + + self.__visible = _tray_icon_handler.create_tray(self) + return self.__visible + + def destroy(self): + """ + Remove the tray icon from the system tray. + + This method hides and destroys the tray icon, making it no longer + visible or interactive in the system notification area. + + Returns: + bool: True if the tray icon was destroyed successfully, False otherwise. + + Note: + * Can only destroy visible tray icons + * Logs a warning if called on non-visible tray icon + * After destruction, the tray icon cannot be made visible again + + Examples: + >>> if tray.visible: + ... success = tray.destroy() + ... if success: + ... print("Tray icon removed") + + Warning: + Calling this method on a non-visible tray icon will log + a warning and return False. + """ + if not self.__visible: + Logger.warning("SystemTray: The tray icon is not visible") + return False + + if _tray_icon_handler: + return _tray_icon_handler.destroy_tray(self) + return False + + @property + def icon(self): + """ + str: Path to the icon image file displayed in the system tray. + + Setting this property will immediately update the tray icon image + if the icon is currently visible. + + Examples: + >>> tray.icon = "/path/to/new_icon.png" # Updates immediately + >>> print(tray.icon) # "/path/to/new_icon.png" + """ + return self.__icon_path + + @icon.setter + def icon(self, value): + self.__icon_path = value + if self.__visible and _tray_icon_handler: + _tray_icon_handler.update_tray_icon_image(self, value) + + @property + def tooltip(self): + """ + str: Text displayed when hovering over the tray icon. + + Setting this property will immediately update the tooltip text + if the tray icon is currently visible. + + Examples: + >>> tray.tooltip = "Application - Status: Online" + >>> print(tray.tooltip) # "Application - Status: Online" + """ + return self.__tooltip + + @tooltip.setter + def tooltip(self, value): + self.__tooltip = value + if self.__visible and _tray_icon_handler: + _tray_icon_handler.update_tray_tooltip(self, value) + + @property + def visible(self): + """ + bool: Whether the tray icon is currently visible in the system tray. + + Read-only property that indicates the current visibility state. + Use :meth:`create` to make visible and :meth:`destroy` to hide. + + Examples: + >>> if not tray.visible: + ... tray.create() + >>> print(tray.visible) # True + """ + return self.__visible + + @property + def menu(self): + """ + TrayMenu: The context menu displayed when clicking the tray icon. + + This property provides access to the menu object for dynamic modification. + Changes to the menu (adding/removing items) are reflected immediately + if the tray icon is currently visible. + + Examples: + >>> # Add new menu item + >>> tray.menu.add_item(TrayMenuItem("New Option", callback=handler)) + + >>> # Clear all menu items + >>> tray.menu.clear() + + >>> # Get current items + >>> items = tray.menu.get_items() + """ + return self.__menu + + def clear_menu(self): + """ + Remove all items from the tray icon's context menu. + + This is a convenience method that calls :meth:`TrayMenu.clear` on the + tray icon's menu. The menu becomes empty and changes are applied + immediately if the tray icon is visible. + + Returns: + bool: True if the menu was cleared successfully, False otherwise. + + Note: + * Requires the tray icon to be visible + * Logs warnings for non-visible icons or missing menus + + Examples: + >>> success = tray.clear_menu() + >>> if success: + ... print("All menu items removed") + + Warning: + This method will return False and log a warning if called on + a non-visible tray icon or if no menu exists. + """ + if not self.__visible: + Logger.warning("SystemTray: Cannot clear menu for non-visible tray icon") + return False + + if not self.__menu: + Logger.warning("SystemTray: No menu exists to clear") + return False + + self.__menu.clear() + return True + + +def _get_install_command(): + """ + Detects the Linux distribution's package manager and returns the + corresponding command to install AppIndicator libraries required + for system tray functionality. + + Returns: + str: Installation command for the detected package manager, or + a generic message if no supported manager is found. + + Supported package managers: + * APT (Debian/Ubuntu): libayatana-appindicator3-1 + * DNF (Fedora/RHEL): libayatana-appindicator-gtk3 + * Pacman (Arch): libayatana-appindicator + * Zypper (openSUSE): libayatana-appindicator3-1 + + """ + system = platform.system().lower() + + if system == "linux": + # Check common package managers + if os.path.exists("/usr/bin/apt"): + return "sudo apt install libayatana-appindicator3-1" + elif os.path.exists("/usr/bin/dnf"): + return "sudo dnf install libayatana-appindicator-gtk3" + elif os.path.exists("/usr/bin/pacman"): + return "sudo pacman -S libayatana-appindicator" + elif os.path.exists("/usr/bin/zypper"): + return "sudo zypper install libayatana-appindicator3-1" + else: + return "Install AppIndicator libraries for your distribution" + + return "System tray libraries may not be available on this platform" + + +def _handle_tray_error(error_message): + """ + Generate logging messages for tray initialization errors. + + Args: + error_message (str): SDL error message from tray creation failure. + + Returns: + list[tuple[str, str]]: List of (log_level, message) tuples. + + Note: + Detects common error types (missing libraries, display issues) + and provides installation commands when applicable. + """ + error_lower = error_message.lower() + install_cmd = _get_install_command() + + # Detect error type and generate appropriate message + if any(keyword in error_lower for keyword in ["gtk", "appindicator", "load"]): + return [ + ("ERROR", "SystemTray: System tray libraries not found"), + ("WARNING", f"SystemTray: To enable system tray: {install_cmd}"), + ("INFO", "SystemTray: Application will continue without tray icon") + ] + elif "display" in error_lower: + return [ + ("ERROR", "SystemTray: Display system doesn't support system tray"), + ("INFO", "SystemTray: Application will continue without tray icon") + ] + else: + return [ + ("ERROR", "SystemTray: Failed to initialize system tray"), + ("WARNING", f"SystemTray: Try installing tray support: {install_cmd}"), + ("INFO", "SystemTray: Application will continue without tray icon") + ] + + +cdef class _TrayIconHandler: + """ + Internal handler class that manages SDL system tray resources and operations. + + This class provides a low-level interface to SDL's system tray functionality, + handling the creation, destruction, and updating of system tray icons and menus. + It serves as the bridge between Python TrayIcon objects and the native SDL + tray implementation. + + The handler manages: + - System tray icon creation and destruction + - Icon image and tooltip updates + - Menu population and item management + - Dynamic menu item property updates (label, enabled state, checked state) + + Note: + This is an internal class and should not be instantiated directly. + It is automatically created when the first TrayIcon is created. + + """ + + cpdef bint create_tray(self, TrayIcon tray_icon): + """ + Creates a system tray icon using SDL. + + This method initializes an SDL tray icon with the specified image, tooltip, + and menu structure. It handles the complete setup process including image + loading, tray creation, and menu population. + + Args: + tray_icon (TrayIcon): The Python TrayIcon object containing configuration + data such as icon path, tooltip, and menu structure. + + Returns: + bool: True if the tray icon was created successfully, False if creation + failed due to missing libraries, invalid icon file, or system limitations. + + Implementation: + 1. Loads the icon image file using SDL_Image + 2. Creates the SDL tray icon with image and tooltip + 3. Creates and populates the context menu if items exist + 4. Registers the tray pointer with the TrayIcon object + 5. Provides detailed error logging for troubleshooting + + Error Handling: + * Invalid icon files result in error logs and creation failure + * Missing system tray support triggers informative error messages + * Memory allocation failures are properly cleaned up + """ + # Load the icon image + cdef bytes icon_path_bytes = tray_icon.icon.encode('utf-8') + cdef bytes tooltip_bytes = tray_icon.tooltip.encode('utf-8') + + Logger.debug(f"SystemTray: Loading icon from {tray_icon.icon}") + cdef SDL_Surface *icon_surface = IMG_Load(icon_path_bytes) + + if not icon_surface: + error = SDL_GetError() + Logger.error(f"SystemTray: Failed to load icon: {error.decode('utf-8', 'replace')}") + return False + + # Create the tray icon + Logger.debug(f"SystemTray: Creating tray with tooltip '{tray_icon.tooltip}'") + cdef SDL_Tray *tray = SDL_CreateTray(icon_surface, tooltip_bytes) + + if not tray: + error = SDL_GetError() + error_message = error.decode('utf-8', 'replace') + + # Generate informative logs + for level, message in _handle_tray_error(error_message): + getattr(Logger, level.lower())(message) + + SDL_DestroySurface(icon_surface) + return False + + # Create and populate the menu if it exists + cdef SDL_TrayMenu *sdl_menu + cdef TrayMenu menu = tray_icon.menu + tray_icon_menu_items = menu.get_items() + if menu and tray_icon_menu_items: + sdl_menu = SDL_CreateTrayMenu(tray) + + if not sdl_menu: + error = SDL_GetError() + Logger.error(f"SystemTray: Failed to create menu: {error.decode('utf-8', 'replace')}") + SDL_DestroyTray(tray) + SDL_DestroySurface(icon_surface) + return False + + # Store the SDL menu pointer in the Python menu object + menu.set_pointer(sdl_menu) + + # Populate the menu with items + self._populate_menu(sdl_menu, menu) + + Logger.info(f"SystemTray: Successfully created tray icon") + + tray_icon.__current_icon_surface = icon_surface + tray_icon.set_pointer(tray) + + return True + + cpdef destroy_tray(self, TrayIcon tray_icon): + """ + Destroys the SDL tray icon and cleans up all associated resources. + + This method safely removes the tray icon from the system notification area + and deallocates all SDL resources to prevent memory leaks. + + Args: + tray_icon (TrayIcon): The TrayIcon object to destroy. Must have a valid + SDL pointer from a previous create_tray() call. + + Returns: + bool: True if destruction was successful, False if the tray icon was + already destroyed or invalid. + + Implementation: + 1. Validates that the tray icon has a valid SDL pointer + 2. Calls SDL_DestroyTray() to remove from system tray + 3. Updates the TrayIcon's visibility state + 4. Resets the SDL pointer to prevent dangling references + 5. Automatically cleans up associated menu resources + + Note: + After calling this method, the TrayIcon object cannot be made visible + again without creating a new instance. + """ + cdef SDL_Tray *tray + if tray_icon.has_valid_pointer(): + Logger.debug("SystemTray: Destroying tray icon") + tray = tray_icon.get_pointer() + SDL_DestroyTray(tray) + tray_icon.__visible = False + tray_icon.set_pointer(0) + return True + return False + + cpdef update_tray_icon_image(self, TrayIcon tray_icon, str icon_path): + """ + Updates the tray icon's image while it's displayed in the system tray. + + This method dynamically changes the tray icon image without requiring + destruction and recreation of the entire tray icon. + + Args: + tray_icon (TrayIcon): The active TrayIcon object to update + icon_path (str): Path to the new icon image file. Must be a valid + image format supported by SDL_Image (PNG, ICO, etc.) + + Returns: + bool: True if the icon was updated successfully, False if the update + failed due to invalid file, unsupported format, or SDL errors. + + Implementation: + 1. Validates that the tray icon is currently active + 2. Loads the new image file using SDL_Image + 3. Updates the tray icon using SDL_SetTrayIcon() + 4. Handles memory management (SDL takes ownership of new surface) + 5. Provides error logging for troubleshooting + + Supported formats: .png, .ico, .bmp, .jpg/.jpeg, .gif, .svg. + + Note: + The old icon surface is automatically freed by SDL when the new + icon is set. Icon size should match system expectations (typically 16x16 or 32x32) for better readability. + """ + if not tray_icon.has_valid_pointer(): + Logger.warning("SystemTray: Cannot update icon for non-existent tray") + return False + + # Load the new icon image + cdef bytes icon_path_bytes = icon_path.encode('utf-8') + cdef SDL_Surface *new_icon_surface = IMG_Load(icon_path_bytes) + + if not new_icon_surface: + error = SDL_GetError() + Logger.error(f"SystemTray: Failed to load new icon: {error.decode('utf-8', 'replace')}") + return False + + # Update the tray icon + cdef SDL_Tray *tray = tray_icon.get_pointer() + SDL_SetTrayIcon(tray, new_icon_surface) + + # Check for errors after the call + error = SDL_GetError() + if error: + Logger.error(f"SystemTray: Failed to update icon: {error.decode('utf-8', 'replace')}") + SDL_DestroySurface(new_icon_surface) + return False + + # Free old surface and replace with new one + SDL_DestroySurface(tray_icon.__current_icon_surface) + tray_icon.__current_icon_surface = new_icon_surface + + return True + + cpdef update_tray_tooltip(self, TrayIcon tray_icon, str tooltip): + """ + Updates the tooltip text displayed when hovering over the tray icon. + + This method dynamically changes the tooltip text for an active tray icon + without requiring recreation. + + Args: + tray_icon (TrayIcon): The active TrayIcon object to update + tooltip (str): New tooltip text to display. Empty strings are allowed + and will show no tooltip when hovering. + + Returns: + bool: True if the tooltip was updated successfully, False if the update + failed due to invalid tray icon or SDL errors. + + Implementation: + 1. Validates that the tray icon is currently active + 2. Encodes the tooltip text to UTF-8 bytes for SDL + 3. Updates the tooltip using SDL_SetTrayTooltip() + 4. Provides error handling and logging + + Note: + Tooltip length limits are platform-dependent. Very long tooltips + may be truncated by the operating system. + """ + if not tray_icon.has_valid_pointer(): + Logger.warning("SystemTray: Cannot update tooltip for non-existent tray") + return False + + cdef bytes tooltip_bytes = tooltip.encode('utf-8') + cdef SDL_Tray *tray = tray_icon.get_pointer() + + SDL_SetTrayTooltip(tray, tooltip_bytes) + + # Check for errors after the call + error = SDL_GetError() + if error: + Logger.error(f"SystemTray: Failed to update tooltip: {error.decode('utf-8', 'replace')}") + return False + + return True + + cdef add_menu_item(self, TrayMenu menu, TrayMenuItem item, index=-1): + """ + Adds a menu item to an existing SDL tray menu at runtime. + + This internal method handles the SDL-level operations required to add + a new menu item to an active tray menu, including proper type mapping, + callback registration, and submenu creation. + + Args: + menu (TrayMenu): The target menu to add the item to. Must have a valid + SDL menu pointer from menu creation. + item (TrayMenuItem): The menu item to add. Will be configured with + appropriate SDL entry type and properties. + index (int): Position to insert the item. Use -1 to append at the end, + or a specific index to insert at that position. + + Returns: + bool: True if the item was added successfully, False if addition failed + due to invalid menu, SDL errors, or resource allocation issues. + + Implementation: + 1. Validates menu has valid SDL pointer + 2. Maps Python item type to SDL entry type constants + 3. Creates SDL entry using SDL_InsertTrayEntryAt() + 4. Registers callback for interactive items + 5. Sets initial state (enabled, checked) as needed + 6. Creates and populates submenus recursively + 7. Associates SDL entry pointer with Python item + + Type Mapping: + * 'checkbox' -> SDL_TRAYENTRY_CHECKBOX + * 'submenu' -> SDL_TRAYENTRY_SUBMENU + * 'button' -> SDL_TRAYENTRY_BUTTON + * 'separator' -> NULL label + """ + if not menu.has_valid_pointer(): + return False + + cdef SDL_TrayMenu *sdl_menu = menu.get_pointer() + cdef SDL_TrayEntry *entry + cdef bytes label_bytes + cdef int entry_type + cdef TrayMenu sub_menu = item.sub_menu + + # Determine entry type + if item.type == 'checkbox': + entry_type = SDL_TRAYENTRY_CHECKBOX + elif item.type == 'submenu': + entry_type = SDL_TRAYENTRY_SUBMENU + else: # Default to button + entry_type = SDL_TRAYENTRY_BUTTON + + # Create the entry + if item.type != 'separator': + label_bytes = item.label.encode('utf-8') + entry = SDL_InsertTrayEntryAt(sdl_menu, index, label_bytes, entry_type) + else: + entry = SDL_InsertTrayEntryAt(sdl_menu, index, NULL, entry_type) + + if not entry: + error = SDL_GetError() + Logger.warning(f"SystemTray: Failed to add menu item: {error.decode('utf-8', 'replace')}") + return False + + # Register the menu item with the entry + item.set_pointer(entry) + + # Set callback for clickable items + if item.type in ('button', 'checkbox'): + SDL_SetTrayEntryCallback(entry, _tray_item_callback, NULL) + + # Set initial enabled state + if not item.enabled: + SDL_SetTrayEntryEnabled(entry, 0) + + # Set initial checked state for checkboxes + if item.type == 'checkbox' and item.checked: + SDL_SetTrayEntryChecked(entry, 1) + + # Create submenu if needed + cdef SDL_TrayMenu *sdl_submenu + if item.type == 'submenu' and sub_menu: + sdl_submenu = SDL_CreateTraySubmenu(entry) + + if not sdl_submenu: + error = SDL_GetError() + Logger.error(f"SystemTray: Failed to create submenu: {error.decode('utf-8', 'replace')}") + return False + + # Store the SDL submenu pointer in the Python submenu object + sub_menu.set_pointer(sdl_submenu) + + # Populate the submenu with items + self._populate_menu(sdl_submenu, sub_menu) + + return True + + cpdef remove_menu_item(self, TrayMenu menu, TrayMenuItem item): + """ + Removes a menu item from an existing SDL tray menu at runtime. + + This method handles the SDL-level operations to remove a menu item from + an active tray menu and clean up associated resources. + + Args: + menu (TrayMenu): The menu containing the item to remove. Must have + a valid SDL menu pointer. + item (TrayMenuItem): The specific menu item to remove. Must have a + valid SDL entry pointer from previous addition. + + Returns: + bool: True if the item was removed successfully, False if removal + failed due to invalid pointers or SDL errors. + + Implementation: + 1. Validates both menu and item have valid SDL pointers + 2. Retrieves SDL entry pointer from the menu item + 3. Calls SDL_RemoveTrayEntry() to remove from menu + 4. Resets the item's SDL pointer to prevent dangling references + 5. Automatically cleans up submenus if the item was a submenu type + + Note: + After removal, the TrayMenuItem object is still valid but can no longer + be used for SDL operations until re-added to a menu. + """ + if not menu.has_valid_pointer() or not item.has_valid_pointer(): + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + + SDL_RemoveTrayEntry(entry) + item.set_pointer(0) + + return True + + cpdef update_menu_item_label(self, TrayMenuItem item): + """ + Updates the display label of an active menu item. + + This method dynamically changes the text shown for a menu item that's + currently part of an active tray menu. + + Args: + item (TrayMenuItem): The menu item to update. Must have a valid SDL + entry pointer and the label property should contain + the new text to display. + + Returns: + bool: True if the label was updated successfully, False if the update + failed due to invalid item pointer. + + Implementation: + 1. Validates the item has a valid SDL entry pointer + 2. Retrieves the current label from the item's label property + 3. Encodes the label text to UTF-8 bytes for SDL + 4. Updates the entry using SDL_SetTrayEntryLabel() + + Note: + The item's label property should be updated before calling this method. + This is typically handled automatically by the TrayMenuItem's label setter. + """ + if not item.has_valid_pointer(): + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + cdef bytes label_bytes = item.label.encode('utf-8') + + SDL_SetTrayEntryLabel(entry, label_bytes) + + return True + + cpdef update_menu_item_enabled(self, TrayMenuItem item): + """ + Updates the enabled/disabled state of an active menu item. + + This method dynamically changes whether a menu item can be clicked or + appears grayed out in the tray menu. + + Args: + item (TrayMenuItem): The menu item to update. Must have a valid SDL + entry pointer and the enabled property should contain + the new state. + + Returns: + bool: True if the enabled state was updated successfully, False if + the update failed due to invalid item pointer. + + Implementation: + 1. Validates the item has a valid SDL entry pointer + 2. Retrieves the current enabled state from the item's enabled property + 3. Updates the entry using SDL_SetTrayEntryEnabled() with 1 for enabled, + 0 for disabled + + Visual Effect: + * Enabled items appear normal and respond to clicks + * Disabled items appear grayed out and ignore click events + + Note: + The item's enabled property should be updated before calling this method. + This is typically handled automatically by the TrayMenuItem's enabled setter. + """ + if not item.has_valid_pointer(): + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + + SDL_SetTrayEntryEnabled(entry, 1 if item.enabled else 0) + + return True + + cpdef update_menu_item_checked(self, TrayMenuItem item): + """ + Updates the checked state of an active checkbox menu item. + + This method dynamically changes the checkmark display for checkbox-type + menu items in the tray menu. + + Args: + item (TrayMenuItem): The checkbox menu item to update. Must have a valid + SDL entry pointer, be of type 'checkbox', and the + checked property should contain the new state. + + Returns: + bool: True if the checked state was updated successfully, False if + the update failed due to invalid item pointer or non-checkbox type. + + Implementation: + 1. Validates the item has a valid SDL entry pointer + 2. Confirms the item type is 'checkbox' (only checkboxes can be checked) + 3. Retrieves the current checked state from the item's checked property + 4. Updates the entry using SDL_SetTrayEntryChecked() with 1 for checked, + 0 for unchecked + + Visual Effect: + * Checked items display a checkmark or similar indicator + * Unchecked items show no indicator + + Note: + Only applies to 'checkbox' type menu items. Other item types will + return False. The item's checked property should be updated before + calling this method, typically handled by the TrayMenuItem's checked setter. + """ + if not item.has_valid_pointer() or item.type != 'checkbox': + return False + + cdef SDL_TrayEntry *entry = item.get_pointer() + + SDL_SetTrayEntryChecked(entry, 1 if item.checked else 0) + + return True + + cdef _populate_menu(self, SDL_TrayMenu *sdl_menu, TrayMenu menu): + """ + Recursively populates an SDL menu with items from a Python TrayMenu. + + This internal method handles the initial population of a newly created + SDL menu with all items from the corresponding Python TrayMenu object. + It supports nested submenus and all menu item types. + + Args: + sdl_menu (SDL_TrayMenu*): The SDL menu structure to populate + menu (TrayMenu): The Python menu object containing items to add + + Implementation: + 1. Iterates through all items in the Python menu + 2. Skips items that already have SDL entries (prevents duplicates) + 3. Maps each item type to appropriate SDL entry type + 4. Creates SDL entries with proper labels and callbacks + 5. Sets initial states (enabled, checked) for each item + 6. Recursively creates and populates submenus + 7. Registers SDL pointers with Python objects for future updates + + Error Handling: + * Individual item creation failures are logged but don't stop processing + * Submenu creation failures are logged as errors + * Memory allocation issues are handled gracefully + + Note: + This method is called during initial tray creation and when adding + submenu items. It ensures proper parent-child relationships between + menus and handles the complete hierarchy setup. + """ + cdef SDL_TrayEntry *entry + cdef bytes label_bytes + cdef int entry_type + + cdef SDL_TrayMenu *sdl_submenu + cdef TrayMenuItem item + cdef TrayMenu sub_menu + + menu_items = menu.get_items() + + for item in menu_items: + # Skip items that already have entries + if item.has_valid_pointer(): + continue + + sub_menu = item.sub_menu + + # Determine entry type + if item.type == 'checkbox': + entry_type = SDL_TRAYENTRY_CHECKBOX + elif item.type == 'submenu': + entry_type = SDL_TRAYENTRY_SUBMENU + else: # Default to button + entry_type = SDL_TRAYENTRY_BUTTON + + # Create the entry + if item.type != 'separator': + label_bytes = item.label.encode('utf-8') + entry = SDL_InsertTrayEntryAt(sdl_menu, -1, label_bytes, entry_type) + else: + entry = SDL_InsertTrayEntryAt(sdl_menu, -1, NULL, entry_type) + + if not entry: + error = SDL_GetError() + Logger.warning(f"SystemTray: Failed to create menu item: {error.decode('utf-8', 'replace')}") + continue + + # Register the menu item with the entry + item.set_pointer(entry) + + # Set callback for clickable items + if item.type in ('button', 'checkbox'): + SDL_SetTrayEntryCallback(entry, _tray_item_callback, NULL) + + # Set initial enabled state + if not item.enabled: + SDL_SetTrayEntryEnabled(entry, 0) + + # Set initial checked state for checkboxes + if item.type == 'checkbox' and item.checked: + SDL_SetTrayEntryChecked(entry, 1) + + # Create submenu if needed + if item.type == 'submenu' and sub_menu: + sdl_submenu = SDL_CreateTraySubmenu(entry) + + if not sdl_submenu: + error = SDL_GetError() + Logger.error(f"SystemTray: Failed to create submenu: {error.decode('utf-8', 'replace')}") + continue + + # Store the SDL submenu pointer in the Python submenu object + sub_menu.set_pointer(sdl_submenu) + + # Populate the submenu with items + self._populate_menu(sdl_submenu, sub_menu) diff --git a/kivy/core/tray/__init__.py b/kivy/core/tray/__init__.py deleted file mode 100644 index ef131ed549..0000000000 --- a/kivy/core/tray/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from kivy.core.tray._tray_sdl3 import * - diff --git a/kivy/core/tray/_tray_sdl3.pyx b/kivy/core/tray/_tray_sdl3.pyx deleted file mode 100644 index 21f6eabd17..0000000000 --- a/kivy/core/tray/_tray_sdl3.pyx +++ /dev/null @@ -1,732 +0,0 @@ -include "../../../kivy/lib/sdl3.pxi" -include "../../include/config.pxi" - -from os import environ -from kivy.config import Config -from kivy.logger import Logger -from kivy.graphics.cgl cimport * -from libc.stdint cimport uintptr_t -from kivy.logger import Logger -from kivy.resources import resource_find -from libc.stdint cimport uintptr_t -import weakref -# from kivy.core.window import WindowBase - - -if not environ.get('KIVY_DOC_INCLUDE'): - is_desktop = Config.get('kivy', 'desktop') == '1' - - -# Global dictionary to track system tray menu items -# Stores weak references to prevent memory leaks -_tray_menu_registry = weakref.WeakValueDictionary() - - -cdef void _tray_item_callback(void *userdata, SDL_TrayEntry *entry) nogil: - """ - C callback function invoked when a tray menu item is clicked. - - Args: - userdata: Pointer containing user data (not used) - entry: SDL_TrayEntry that was clicked - """ - with gil: - # Find the menu item for this entry - addr = entry - py_addr = addr - menu_item = _tray_menu_registry.get(py_addr) - - if menu_item is None: - Logger.warning(f"TrayIcon: No menu item found for pointer {entry}") - return - - # For checkbox items, toggle the checked state - if menu_item.type == 'checkbox': - menu_item.checked = not menu_item.checked - - # Call the menu item callback if it exists - if menu_item.callback: - try: - menu_item.callback(menu_item) - except Exception as e: - Logger.error(f"TrayIcon: Exception in callback: {e}") - - -cdef class _SDLPointerHandler: - - cdef uintptr_t __pointer - - def __cinit__(self): - self.__pointer = 0 # Initialized as NULL - - cdef uintptr_t get_pointer(self): - """Gets the SDL pointer associated with this item.""" - return self.__pointer - - cdef void set_pointer(self, uintptr_t value): - """Sets the SDL pointer associated with this item.""" - if value < 0: - raise ValueError("Invalid pointer value") - self.__pointer = value - self._on_pointer_set(value) - - cdef void _on_pointer_set(self, uintptr_t value): - pass - - cdef bint has_valid_pointer(self): - """Returns whether this object has a valid SDL pointer.""" - return self.__pointer != 0 - - cpdef bint _can_update(self): - """Returns whether this object can be updated via the tray icon handler.""" - global _tray_icon_handler - return self.has_valid_pointer() and _tray_icon_handler is not None - - -cdef class TrayMenuItem(_SDLPointerHandler): - """ - Represents a single item in a system tray menu. - - Properties: - label (str): The text shown on the menu item - enabled (bool): Whether the menu item is clickable or disabled - checked (bool): Whether the menu item shows a checkmark (for checkbox items) - type (str): The menu item type ('button', 'checkbox', 'separator', 'submenu') - menu (TrayMenu): For submenu type items, contains the submenu items - """ - - cdef object __weakref__ - - cdef str __label - cdef object __callback - cdef bint __enabled - cdef bint __checked - cdef str __type - cdef TrayMenu __parent_menu - - def __init__(self, label='', callback=None, enabled=True, checked=False, - type='button', menu=None, **kwargs): - """ - Initializes a tray menu item. - - Args: - label (str): The text shown for this menu item - callback (callable): Function to call when the item is clicked - enabled (bool): Whether the item is enabled (can be clicked) - checked (bool): Whether the item shows a checkmark - type (str): The menu item type ('button', 'checkbox', 'separator', 'submenu') - menu (TrayMenu): For submenu types, contains submenu items - """ - self.__label = label - self.__callback = callback - self.__enabled = enabled - self.__checked = checked - self.__type = type - self.__parent_menu = menu - - cpdef void _on_pointer_set(self, uintptr_t value): - if value != 0: # If not NULL - _tray_menu_registry[value] = self - - @property - def label(self): - """The text shown on the menu item.""" - return self.__label - - @label.setter - def label(self, value): - if value != self.__label: - self.__label = value - if self._can_update(): - _tray_icon_handler.update_menu_item_label(self) - - @property - def enabled(self): - """Whether the menu item is clickable or disabled.""" - return self.__enabled - - @enabled.setter - def enabled(self, value): - if value != self.__enabled: - self.__enabled = value - if self._can_update(): - _tray_icon_handler.update_menu_item_enabled(self) - - @property - def checked(self): - """Whether the menu item shows a checkmark (for checkbox items).""" - return self.__checked - - @checked.setter - def checked(self, value): - if value != self.__checked: - self.__checked = value - if self._can_update(): - _tray_icon_handler.update_menu_item_checked(self) - - @property - def callback(self): - """Function to call when the item is clicked.""" - return self.__callback - - @callback.setter - def callback(self, value): - if value != self.__callback: - self.__callback = value - - @property - def type(self): - """The menu item type ('button', 'checkbox', 'separator', 'submenu').""" - return self.__type - - @property - def parent_menu(self): - """For submenu type items, contains the submenu items.""" - return self.__parent_menu - - -cdef class TrayMenu(_SDLPointerHandler): - """Represents a system tray menu or submenu.""" - - cdef list __items - - def __init__(self) -> None: - self.__items = [] - - cpdef list get_items(self): - return self.__items - - cpdef void add_item(self, TrayMenuItem item, index=-1): - """ - Add an item to the menu. - - Args: - item: TrayMenuItem or dict to add to the menu - index: Index where to insert the item (-1 to append to the end) - """ - if isinstance(item, dict): - # Handle submenu case - if 'submenu' in item: - submenu = TrayMenu(items=item.pop('submenu')) - item['menu'] = submenu - item['type'] = 'submenu' - item = TrayMenuItem(**item) - elif item == 'separator': - item = TrayMenuItem(type='separator') - - if not isinstance(item, TrayMenuItem): - Logger.warning(f"SystemTray: Cannot add invalid item type: {type(item)}") - return - - # If the menu already exists in SDL, add the item there too - if self._can_update(): - _tray_icon_handler.add_menu_item(self, item, index) - - if index < 0 or index >= len(self.__items): - self.__items.append(item) - else: - self.__items.insert(index, item) - - cpdef void remove_item(self, TrayMenuItem item): - """ - Remove an item from the menu. - - Args: - item: TrayMenuItem to remove - """ - if item in self.__items: - # If the menu exists in SDL, remove the item from there too - if self.has_valid_pointer() and item.has_valid_pointer() and _tray_icon_handler: - _tray_icon_handler.remove_menu_item(self, item) - - self.__items.remove(item) - - cpdef void clear(self): - """Remove all items from the menu.""" - # If the menu exists in SDL, remove all items from there too - cdef TrayMenuItem item - if self._can_update(): - for item in list(self.__items): - if item.has_valid_pointer(): - _tray_icon_handler.remove_menu_item(self, item) - - self.__items = [] - - -cdef class TrayIcon(_SDLPointerHandler): - """ - Manages a system tray icon with menu functionality. - - Properties: - icon_path (str): Path to the icon image - tooltip (str): Tooltip text shown when hovering over the tray icon - menu (TrayMenu): The menu displayed when clicking on the tray icon - visible (bool): Whether the tray icon is currently visible - """ - - cdef str __icon_path - cdef str __tooltip - cdef TrayMenu __menu - cdef bint __visible - - def __init__(self, icon_path='', tooltip='Kivy Application', menu=None, **kwargs): - """ - Initializes a system tray icon. - - Args: - icon_path (str): Path to the icon image file - tooltip (str): Text to be displayed when hovering over the icon - menu (TrayMenu or list): Menu to be displayed when clicking on the icon - """ - self.__icon_path = icon_path or resource_find('data/logo/kivy-icon-32.png') - self.tooltip = tooltip - - # Process the menu if provided - if menu: - if isinstance(menu, list): - self.__menu = TrayMenu(items=menu) - elif isinstance(menu, TrayMenu): - self.__menu = menu - else: - Logger.warning(f"TrayIcon: Invalid menu type: {type(menu)}") - self.__menu = TrayMenu() - else: - self.__menu = TrayMenu() - - cpdef bint create(self): - """Creates and displays the tray icon.""" - if self.__visible: - Logger.warning("TrayIcon: The tray icon is already visible") - return False - - global _tray_icon_handler - if _tray_icon_handler is None: - _tray_icon_handler = _TrayIconHandler() - - self.__visible = _tray_icon_handler.create_tray(self) - return self.__visible - - def destroy(self): - """Removes the tray icon from the system tray.""" - if not self.__visible: - Logger.warning("TrayIcon: The tray icon is not visible") - return False - - if _tray_icon_handler: - return _tray_icon_handler.destroy_tray(self) - return False - - @property - def icon(self): - return self.__icon_path - - @icon.setter - def icon(self, value): - """ - Updates the tray icon image. - - Args: - value (str): Path to the new icon image file - """ - self.__icon_path = value - if self.__visible and _tray_icon_handler: - _tray_icon_handler.update_tray(self, value) - - @property - def tooltip(self): - return self.__tooltip - - @tooltip.setter - def tooltip(self, value): - """ - Updates the tray icon tooltip. - - Args: - value (str): New tooltip text - """ - self.__tooltip = value - if self.__visible and _tray_icon_handler: - _tray_icon_handler.update_tray_tooltip(self, value) - - @property - def visible(self): - return self.__visible - - @visible.setter - def visible(self, value): - self.__visible = value - - @property - def menu(self): - return self.__menu - - def clear_menu(self): - """ - Removes all items from the tray icon's menu. - - Returns: - bool: True if successful, False otherwise - """ - if not self.__visible: - Logger.warning("TrayIcon: Cannot clear menu for non-visible tray icon") - return False - - if not self.__menu: - Logger.warning("TrayIcon: No menu exists to clear") - return False - - self.__menu.clear() - return True - - -# Global variable to store the handler instance -cdef _TrayIconHandler _tray_icon_handler - - -cdef class _TrayIconHandler: - """Internal class for managing SDL tray icon resources.""" - - cpdef bint create_tray(self, TrayIcon tray_icon): - """ - Creates a system tray icon. - - Args: - tray_icon: Python TrayIcon object - """ - # Load the icon image - cdef bytes icon_path_bytes = tray_icon.icon.encode('utf-8') - cdef bytes tooltip_bytes = tray_icon.tooltip.encode('utf-8') - - Logger.debug(f"TrayIcon: Loading icon from {tray_icon.icon}") - cdef SDL_Surface *icon_surface = IMG_Load(icon_path_bytes) - - if not icon_surface: - error = SDL_GetError() - Logger.error(f"TrayIcon: Failed to load icon: {error.decode('utf-8', 'replace')}") - return False - - # Create the tray icon - Logger.debug(f"TrayIcon: Creating tray with tooltip '{tray_icon.tooltip}'") - cdef SDL_Tray *tray = SDL_CreateTray(icon_surface, tooltip_bytes) - - if not tray: - error = SDL_GetError() - Logger.error(f"TrayIcon: Failed to create tray: {error.decode('utf-8', 'replace')}") - SDL_DestroySurface(icon_surface) - return False - - # Create and populate the menu if it exists - cdef SDL_TrayMenu *sdl_menu - cdef TrayMenu menu = tray_icon.menu - tray_icon_menu_items = menu.get_items() - if menu and tray_icon_menu_items: - sdl_menu = SDL_CreateTrayMenu(tray) - - if not sdl_menu: - error = SDL_GetError() - Logger.error(f"TrayIcon: Failed to create menu: {error.decode('utf-8', 'replace')}") - SDL_DestroyTray(tray) - SDL_DestroySurface(icon_surface) - return False - - # Store the SDL menu pointer in the Python menu object - menu.set_pointer(sdl_menu) - - # Populate the menu with items - self._populate_menu(sdl_menu, menu) - - Logger.info(f"TrayIcon: Successfully created tray icon") - - tray_icon.set_pointer(tray) - - return True - - cpdef destroy_tray(self, TrayIcon tray_icon): - """ - Destroys the tray icon and cleans up resources. - - Args: - tray_icon: Python TrayIcon object - """ - cdef SDL_Tray *tray - if tray_icon.has_valid_pointer(): - Logger.debug("TrayIcon: Destroying tray icon") - tray = tray_icon.get_pointer() - SDL_DestroyTray(tray) - tray_icon.visible = False - tray_icon.set_pointer(0) - return True - return False - - cpdef update_tray(self, TrayIcon tray_icon, str icon_path): - """ - Updates the tray icon image. - - Args: - tray_icon: Python TrayIcon object - icon_path: Path to the new icon image file - """ - if not tray_icon.has_valid_pointer(): - Logger.warning("TrayIcon: Cannot update icon for non-existent tray") - return False - - # Load the new icon image - cdef bytes icon_path_bytes = icon_path.encode('utf-8') - cdef SDL_Surface *new_icon_surface = IMG_Load(icon_path_bytes) - - if not new_icon_surface: - error = SDL_GetError() - Logger.error(f"TrayIcon: Failed to load new icon: {error.decode('utf-8', 'replace')}") - return False - - # Update the tray icon - cdef SDL_Tray *tray = tray_icon.get_pointer() - SDL_SetTrayIcon(tray, new_icon_surface) - - # Check for errors after the call - error = SDL_GetError() - if error: - Logger.error(f"TrayIcon: Failed to update icon: {error.decode('utf-8', 'replace')}") - SDL_DestroySurface(new_icon_surface) - return False - - # SDL_SetTrayIcon takes ownership of the surface, we don't need to destroy it - return True - - cpdef update_tray_tooltip(self, TrayIcon tray_icon, str tooltip): - """ - Updates the tray icon tooltip. - - Args: - tray_icon: Python TrayIcon object - tooltip: New tooltip text - """ - if not tray_icon.has_valid_pointer(): - Logger.warning("TrayIcon: Cannot update tooltip for non-existent tray") - return False - - cdef bytes tooltip_bytes = tooltip.encode('utf-8') - cdef SDL_Tray *tray = tray_icon.get_pointer() - - SDL_SetTrayTooltip(tray, tooltip_bytes) - - # Check for errors after the call - error = SDL_GetError() - if error: - Logger.error(f"TrayIcon: Failed to update tooltip: {error.decode('utf-8', 'replace')}") - return False - - return True - - cdef add_menu_item(self, TrayMenu menu, TrayMenuItem item, index=-1): - """ - Adds a menu item to an existing menu. - - Args: - menu: Python TrayMenu object - item: Python TrayMenuItem object - index: Index where to insert the item (-1 to append at the end) - """ - if not menu.has_valid_pointer(): - return False - - cdef SDL_TrayMenu *sdl_menu = menu.get_pointer() - cdef SDL_TrayEntry *entry - cdef bytes label_bytes - cdef int entry_type - cdef TrayMenu parent_menu = item.parent_menu - - # Determine entry type - if item.type == 'checkbox': - entry_type = SDL_TRAYENTRY_CHECKBOX - elif item.type == 'submenu': - entry_type = SDL_TRAYENTRY_SUBMENU - else: # Default to button - entry_type = SDL_TRAYENTRY_BUTTON - - # Create the entry - if item.type != 'separator': - label_bytes = item.label.encode('utf-8') - entry = SDL_InsertTrayEntryAt(sdl_menu, index, label_bytes, entry_type) - else: - entry = SDL_InsertTrayEntryAt(sdl_menu, index, NULL, entry_type) - - if not entry: - error = SDL_GetError() - Logger.warning(f"TrayIcon: Failed to add menu item: {error.decode('utf-8', 'replace')}") - return False - - # Register the menu item with the entry - item.set_pointer(entry) - - # Set callback for clickable items - if item.type in ('button', 'checkbox'): - SDL_SetTrayEntryCallback(entry, _tray_item_callback, NULL) - - # Set initial enabled state - if not item.enabled: - SDL_SetTrayEntryEnabled(entry, 0) - - # Set initial checked state for checkboxes - if item.type == 'checkbox' and item.checked: - SDL_SetTrayEntryChecked(entry, 1) - - # Create submenu if needed - cdef SDL_TrayMenu *sdl_submenu - if item.type == 'submenu' and parent_menu: - sdl_submenu = SDL_CreateTraySubmenu(entry) - - if not sdl_submenu: - error = SDL_GetError() - Logger.error(f"TrayIcon: Failed to create submenu: {error.decode('utf-8', 'replace')}") - return False - - # Store the SDL submenu pointer in the Python submenu object - parent_menu.set_pointer(sdl_submenu) - - # Populate the submenu with items - self._populate_menu(sdl_submenu, parent_menu) - - return True - - cpdef remove_menu_item(self, TrayMenu menu, TrayMenuItem item): - """ - Removes a menu item from an existing menu. - - Args: - menu: Python TrayMenu object - item: Python TrayMenuItem object - """ - if not menu.has_valid_pointer() or not item.has_valid_pointer(): - return False - - cdef SDL_TrayEntry *entry = item.get_pointer() - - SDL_RemoveTrayEntry(entry) - item.set_pointer(0) - - return True - - cpdef update_menu_item_label(self, TrayMenuItem item): - """ - Updates a menu item's label. - - Args: - item: Python TrayMenuItem object - """ - if not item.has_valid_pointer(): - return False - - cdef SDL_TrayEntry *entry = item.get_pointer() - cdef bytes label_bytes = item.label.encode('utf-8') - - SDL_SetTrayEntryLabel(entry, label_bytes) - - return True - - cpdef update_menu_item_enabled(self, TrayMenuItem item): - """ - Updates a menu item's enabled state. - - Args: - item: Python TrayMenuItem object - """ - if not item.has_valid_pointer(): - return False - - cdef SDL_TrayEntry *entry = item.get_pointer() - - SDL_SetTrayEntryEnabled(entry, 1 if item.enabled else 0) - - return True - - cpdef update_menu_item_checked(self, TrayMenuItem item): - """ - Updates a menu item's checked state. - - Args: - item: Python TrayMenuItem object - """ - if not item.has_valid_pointer() or item.type != 'checkbox': - return False - - cdef SDL_TrayEntry *entry = item.get_pointer() - - SDL_SetTrayEntryChecked(entry, 1 if item.checked else 0) - - return True - - cdef _populate_menu(self, SDL_TrayMenu *sdl_menu, TrayMenu menu): - """ - Populates a menu with items. - - Args: - sdl_menu: SDL_TrayMenu pointer - menu: Python TrayMenu object - """ - cdef SDL_TrayEntry *entry - cdef bytes label_bytes - cdef int entry_type - - cdef SDL_TrayMenu *sdl_submenu - cdef TrayMenuItem item - cdef TrayMenu parent_menu - - menu_items = menu.get_items() - - for item in menu_items: - # Skip items that already have entries - if item.has_valid_pointer(): - continue - - parent_menu = item.parent_menu - - # Determine entry type - if item.type == 'checkbox': - entry_type = SDL_TRAYENTRY_CHECKBOX - elif item.type == 'submenu': - entry_type = SDL_TRAYENTRY_SUBMENU - else: # Default to button - entry_type = SDL_TRAYENTRY_BUTTON - - # Create the entry - if item.type != 'separator': - label_bytes = item.label.encode('utf-8') - entry = SDL_InsertTrayEntryAt(sdl_menu, -1, label_bytes, entry_type) - else: - entry = SDL_InsertTrayEntryAt(sdl_menu, -1, NULL, entry_type) - - if not entry: - error = SDL_GetError() - Logger.warning(f"TrayIcon: Failed to create menu item: {error.decode('utf-8', 'replace')}") - continue - - # Register the menu item with the entry - item.set_pointer(entry) - - # Set callback for clickable items - if item.type in ('button', 'checkbox'): - SDL_SetTrayEntryCallback(entry, _tray_item_callback, NULL) - - # Set initial enabled state - if not item.enabled: - SDL_SetTrayEntryEnabled(entry, 0) - - # Set initial checked state for checkboxes - if item.type == 'checkbox' and item.checked: - SDL_SetTrayEntryChecked(entry, 1) - - # Create submenu if needed - if item.type == 'submenu' and parent_menu: - sdl_submenu = SDL_CreateTraySubmenu(entry) - - if not sdl_submenu: - error = SDL_GetError() - Logger.error(f"TrayIcon: Failed to create submenu: {error.decode('utf-8', 'replace')}") - continue - - # Store the SDL submenu pointer in the Python submenu object - parent_menu.set_pointer(sdl_submenu) - - # Populate the submenu with items - self._populate_menu(sdl_submenu, parent_menu) diff --git a/kivy/core/window/window_sdl3.py b/kivy/core/window/window_sdl3.py index 353201c0a1..871a41da9c 100644 --- a/kivy/core/window/window_sdl3.py +++ b/kivy/core/window/window_sdl3.py @@ -502,7 +502,7 @@ def mainloop(self): continue action, args = event[0], event[1:] - if action == 'quit': + if action in ('windowclose', 'quit'): if self.dispatch('on_request_close'): continue EventLoop.quit = True @@ -777,7 +777,7 @@ def do_pause(self): continue action, args = event[0], event[1:] - if action == 'quit': + if action in ('windowclose', 'quit'): EventLoop.quit = True break elif action == 'app_willenterforeground': diff --git a/kivy/tests/test_system_tray.py b/kivy/tests/test_system_tray.py new file mode 100644 index 0000000000..2414749854 --- /dev/null +++ b/kivy/tests/test_system_tray.py @@ -0,0 +1,572 @@ +import os +import tempfile +from unittest.mock import Mock + +from kivy.tests import GraphicUnitTest +from kivy.logger import LoggerHistory +from kivy.core.system_tray import TrayIcon, TrayMenu, TrayMenuItem +from kivy.resources import resource_find + + +class TrayMenuTest(GraphicUnitTest): + """Test TrayMenu functionality""" + + def setUp(self): + super().setUp() + self.parent_menu = TrayMenu() + + def test_menu_creation(self): + """Test that TrayMenu can be created""" + self.assertIsInstance(self.parent_menu, TrayMenu) + self.assertEqual(len(self.parent_menu.get_items()), 0) + + def test_add_item(self): + """Test adding items to menu""" + item = TrayMenuItem(label="Test Item", type="button") + self.parent_menu.add_item(item) + + items = self.parent_menu.get_items() + self.assertEqual(len(items), 1) + self.assertEqual(items[0], item) + + def test_add_item_at_index(self): + """Test adding items at specific index""" + item1 = TrayMenuItem(label="Item 1", type="button") + item2 = TrayMenuItem(label="Item 2", type="button") + item3 = TrayMenuItem(label="Item 3", type="button") + + self.parent_menu.add_item(item1) + self.parent_menu.add_item(item3) + self.parent_menu.add_item(item2, 1) # Insert at index 1 + + items = self.parent_menu.get_items() + self.assertEqual(len(items), 3) + self.assertEqual(items[0].label, "Item 1") + self.assertEqual(items[1].label, "Item 2") + self.assertEqual(items[2].label, "Item 3") + + def test_remove_item(self): + """Test removing items from menu""" + item1 = TrayMenuItem(label="Item 1", type="button") + item2 = TrayMenuItem(label="Item 2", type="button") + + self.parent_menu.add_item(item1) + self.parent_menu.add_item(item2) + self.assertEqual(len(self.parent_menu.get_items()), 2) + + self.parent_menu.remove_item(item1) + items = self.parent_menu.get_items() + self.assertEqual(len(items), 1) + self.assertEqual(items[0], item2) + + def test_clear_menu(self): + """Test clearing all items from menu""" + for i in range(5): + item = TrayMenuItem(label=f"Item {i}", type="button") + self.parent_menu.add_item(item) + + self.assertEqual(len(self.parent_menu.get_items()), 5) + + self.parent_menu.clear() + self.assertEqual(len(self.parent_menu.get_items()), 0) + + def test_get_items_returns_reference(self): + """Test that get_items returns the actual list (based on implementation)""" + item = TrayMenuItem(label="Test Item", type="button") + self.parent_menu.add_item(item) + + items1 = self.parent_menu.get_items() + items2 = self.parent_menu.get_items() + + # Based on the implementation, it returns the same list reference + self.assertEqual(items1, items2) + self.assertIs(items1, items2) + + +class TrayMenuItemTest(GraphicUnitTest): + """Test TrayMenuItem functionality""" + + def test_button_item_creation(self): + """Test creating button menu item""" + callback = Mock() + item = TrayMenuItem( + label="Test Button", type="button", callback=callback + ) + + self.assertEqual(item.label, "Test Button") + self.assertEqual(item.type, "button") + self.assertEqual(item.callback, callback) + self.assertFalse(item.checked) # Default for button + + def test_checkbox_item_creation(self): + """Test creating checkbox menu item""" + callback = Mock() + item = TrayMenuItem( + label="Test Checkbox", + type="checkbox", + checked=True, + callback=callback, + ) + + self.assertEqual(item.label, "Test Checkbox") + self.assertEqual(item.type, "checkbox") + self.assertTrue(item.checked) + self.assertEqual(item.callback, callback) + + def test_separator_item_creation(self): + """Test creating separator menu item""" + item = TrayMenuItem(type="separator") + + self.assertEqual(item.type, "separator") + # Based on implementation, label defaults to empty string, not None + self.assertEqual(item.label, "") + self.assertIsNone(item.callback) + + def test_submenu_item_creation(self): + """Test creating submenu item""" + submenu = TrayMenu() + submenu_item = TrayMenuItem( + label="Submenu", type="submenu", menu=submenu + ) + + self.assertEqual(submenu_item.label, "Submenu") + self.assertEqual(submenu_item.type, "submenu") + self.assertEqual(submenu_item.sub_menu, submenu) + + def test_item_property_modification(self): + """Test modifying item properties after creation""" + item = TrayMenuItem(label="Original", type="button") + + item.label = "Modified" + item.checked = True + + self.assertEqual(item.label, "Modified") + self.assertTrue(item.checked) + + def test_callback_execution(self): + """Test that callback is properly stored and can be called""" + callback = Mock() + item = TrayMenuItem(label="Test Item", type="button", callback=callback) + + # Simulate clicking the item + if item.callback: + item.callback(item) + + callback.assert_called_once_with(item) + + def test_checkbox_state_toggle(self): + """Test checkbox state can be toggled""" + item = TrayMenuItem(label="Toggle Test", type="checkbox", checked=False) + + self.assertFalse(item.checked) + + item.checked = True + self.assertTrue(item.checked) + + item.checked = False + self.assertFalse(item.checked) + + +class TrayIconTest(GraphicUnitTest): + """Test TrayIcon functionality""" + + def setUp(self): + super().setUp() + self.parent_menu = TrayMenu() + self.test_icon_path = resource_find("data/logo/kivy-icon-32.png") + if not self.test_icon_path: + # Create a temporary icon file for testing + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + self.test_icon_path = f.name + # Write minimal PNG data + f.write( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\tpHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\x00\x00\x00\nIDATx\x9cc```\x00\x00\x00\x04\x00\x01]\xcc\xdb\x8a\x00\x00\x00\x00IEND\xaeB`\x82" + ) + self._temp_icon = True + else: + self._temp_icon = False + + def tearDown(self): + if self._temp_icon and os.path.exists(self.test_icon_path): + os.unlink(self.test_icon_path) + super().tearDown() + + def test_tray_icon_creation(self): + """Test basic tray icon creation""" + tray_icon = TrayIcon( + icon_path=self.test_icon_path, + tooltip="Test Tooltip", + menu=self.parent_menu, + ) + tray_icon.create() + + self.assertEqual(tray_icon.visible, True) + self.assertEqual(tray_icon.icon, self.test_icon_path) + self.assertEqual(tray_icon.tooltip, "Test Tooltip") + # Based on implementation, the property is 'menu', not 'parent_menu' + self.assertEqual(tray_icon.menu, self.parent_menu) + + def test_tray_icon_creation_minimal(self): + """Test tray icon creation with minimal parameters""" + tray_icon = TrayIcon(icon_path=self.test_icon_path) + + self.assertEqual(tray_icon.icon, self.test_icon_path) + # Based on implementation, default tooltip is "Kivy Application", not None + self.assertEqual(tray_icon.tooltip, "Kivy Application") + # Menu should be created automatically as empty TrayMenu + self.assertIsInstance(tray_icon.menu, TrayMenu) + + def test_tray_icon_property_modification(self): + """Test modifying tray icon properties""" + tray_icon = TrayIcon(icon_path=self.test_icon_path) + + tray_icon.tooltip = "New Tooltip" + # Cannot directly set menu property, but we can verify it exists + self.assertEqual(tray_icon.tooltip, "New Tooltip") + self.assertIsInstance(tray_icon.menu, TrayMenu) + + def test_tray_icon_with_populated_menu(self): + """Test tray icon with a menu containing items""" + # Add items to menu + item1 = TrayMenuItem(label="Item 1", type="button") + item2 = TrayMenuItem(label="Item 2", type="checkbox", checked=True) + separator = TrayMenuItem(type="separator") + + self.parent_menu.add_item(item1) + self.parent_menu.add_item(separator) + self.parent_menu.add_item(item2) + + tray_icon = TrayIcon( + icon_path=self.test_icon_path, + tooltip="Test with Menu", + menu=self.parent_menu, + ) + + self.assertEqual(len(tray_icon.menu.get_items()), 3) + items = tray_icon.menu.get_items() + self.assertEqual(items[0].label, "Item 1") + self.assertEqual(items[1].type, "separator") + self.assertEqual(items[2].label, "Item 2") + self.assertTrue(items[2].checked) + + +class TrayIconFileHandlingTest(GraphicUnitTest): + """Test TrayIcon file handling capabilities""" + + def setUp(self): + super().setUp() + self.temp_files = [] + + def tearDown(self): + # Clean up temporary files + for temp_file in self.temp_files: + if os.path.exists(temp_file): + os.unlink(temp_file) + super().tearDown() + + def create_temp_image_file(self, suffix=".png", content=None): + """Create temporary image file for testing""" + with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f: + if content: + f.write(content) + else: + # Write minimal PNG data + f.write( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\tpHYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\x00\x00\x00\nIDATx\x9cc```\x00\x00\x00\x04\x00\x01]\xcc\xdb\x8a\x00\x00\x00\x00IEND\xaeB`\x82" + ) + temp_path = f.name + + self.temp_files.append(temp_path) + return temp_path + + def test_icon_path_validation_png(self): + """Test tray icon with PNG file""" + png_path = self.create_temp_image_file(".png") + tray_icon = TrayIcon(icon_path=png_path) + + self.assertEqual(tray_icon.icon, png_path) + self.assertTrue(os.path.exists(tray_icon.icon)) + + def test_icon_path_validation_ico(self): + """Test tray icon with ICO file""" + ico_path = self.create_temp_image_file(".ico") + tray_icon = TrayIcon(icon_path=ico_path) + + self.assertEqual(tray_icon.icon, ico_path) + + def test_icon_path_validation_jpg(self): + """Test tray icon with JPG file""" + jpg_path = self.create_temp_image_file(".jpg") + tray_icon = TrayIcon(icon_path=jpg_path) + + self.assertEqual(tray_icon.icon, jpg_path) + + def test_supported_file_extensions(self): + """Test various supported file extensions""" + extensions = [".png", ".ico", ".jpg", ".jpeg", ".gif", ".svg"] + + for ext in extensions: + with self.subTest(extension=ext): + temp_path = self.create_temp_image_file(ext) + tray_icon = TrayIcon(icon_path=temp_path) + self.assertEqual(tray_icon.icon, temp_path) + + def test_nonexistent_file_handling(self): + """Test handling of nonexistent icon file""" + nonexistent_path = "/path/to/nonexistent/file.png" + + # TrayIcon should still be created, but behavior may vary by platform + tray_icon = TrayIcon(icon_path=nonexistent_path) + self.assertEqual(tray_icon.icon, nonexistent_path) + + +class TrayComplexMenuTest(GraphicUnitTest): + """Test complex menu structures and interactions""" + + def setUp(self): + super().setUp() + self.main_menu = TrayMenu() + self.callback_results = [] + + def callback_method(self, item): + """Callback function for menu items""" + self.callback_results.append(f"Clicked: {item.label}") + + def test_nested_submenu_structure(self): + """Test creating nested submenus""" + # Create main menu items + item1 = TrayMenuItem( + label="Main Item 1", type="button", callback=self.callback_method + ) + separator1 = TrayMenuItem(type="separator") + + # Create submenu + submenu = TrayMenu() + sub_item1 = TrayMenuItem( + label="Sub Item 1", type="button", callback=self.callback_method + ) + sub_item2 = TrayMenuItem( + label="Sub Item 2", + type="checkbox", + checked=True, + callback=self.callback_method, + ) + + submenu.add_item(sub_item1) + submenu.add_item(sub_item2) + + submenu_item = TrayMenuItem( + label="Submenu", type="submenu", menu=submenu + ) + + # Create nested submenu + nested_submenu = TrayMenu() + nested_item = TrayMenuItem( + label="Nested Item", type="button", callback=self.callback_method + ) + nested_submenu.add_item(nested_item) + + nested_submenu_item = TrayMenuItem( + label="Nested Submenu", type="submenu", menu=nested_submenu + ) + submenu.add_item(nested_submenu_item) + + # Add to main menu + self.main_menu.add_item(item1) + self.main_menu.add_item(separator1) + self.main_menu.add_item(submenu_item) + + # Verify structure + main_items = self.main_menu.get_items() + self.assertEqual(len(main_items), 3) + self.assertEqual(main_items[0].label, "Main Item 1") + self.assertEqual(main_items[1].type, "separator") + self.assertEqual(main_items[2].label, "Submenu") + + # Verify submenu + sub_items = main_items[2].sub_menu.get_items() + self.assertEqual(len(sub_items), 3) + self.assertEqual(sub_items[2].type, "submenu") + + # Verify nested submenu + nested_items = sub_items[2].sub_menu.get_items() + self.assertEqual(len(nested_items), 1) + self.assertEqual(nested_items[0].label, "Nested Item") + + def test_mixed_item_types_menu(self): + """Test menu with various item types""" + items_data = [ + ("Button Item", "button", False), + ("Checkbox Unchecked", "checkbox", False), + ("Checkbox Checked", "checkbox", True), + (None, "separator", False), + ("Another Button", "button", False), + ] + + for label, item_type, checked in items_data: + if item_type == "separator": + item = TrayMenuItem(type="separator") + else: + item = TrayMenuItem( + label=label, + type=item_type, + checked=checked, + callback=self.callback_method, + ) + self.main_menu.add_item(item) + + items = self.main_menu.get_items() + self.assertEqual(len(items), 5) + + # Verify each item + self.assertEqual(items[0].label, "Button Item") + self.assertEqual(items[0].type, "button") + + self.assertEqual(items[1].label, "Checkbox Unchecked") + self.assertEqual(items[1].type, "checkbox") + self.assertFalse(items[1].checked) + + self.assertEqual(items[2].label, "Checkbox Checked") + self.assertEqual(items[2].type, "checkbox") + self.assertTrue(items[2].checked) + + self.assertEqual(items[3].type, "separator") + # Based on implementation, separator label is empty string, not None + self.assertEqual(items[3].label, "") + + self.assertEqual(items[4].label, "Another Button") + self.assertEqual(items[4].type, "button") + + def test_callback_execution_tracking(self): + """Test that callbacks are properly executed and tracked""" + item1 = TrayMenuItem( + label="Test Item 1", type="button", callback=self.callback_method + ) + item2 = TrayMenuItem( + label="Test Item 2", type="checkbox", callback=self.callback_method + ) + + self.main_menu.add_item(item1) + self.main_menu.add_item(item2) + + # Simulate clicking items + self.callback_results.clear() + + if item1.callback: + item1.callback(item1) + if item2.callback: + item2.callback(item2) + + self.assertEqual(len(self.callback_results), 2) + self.assertEqual(self.callback_results[0], "Clicked: Test Item 1") + self.assertEqual(self.callback_results[1], "Clicked: Test Item 2") + + def test_large_menu_performance(self): + """Test menu with many items for performance""" + num_items = 100 + + for i in range(num_items): + item_type = "checkbox" if i % 3 == 0 else "button" + checked = i % 2 == 0 if item_type == "checkbox" else False + + item = TrayMenuItem( + label=f"Item {i}", + type=item_type, + checked=checked, + callback=self.callback_method, + ) + self.main_menu.add_item(item) + + items = self.main_menu.get_items() + self.assertEqual(len(items), num_items) + + # Verify random items + self.assertEqual(items[0].label, "Item 0") + self.assertEqual(items[50].label, "Item 50") + self.assertEqual(items[99].label, "Item 99") + + # Test clearing large menu + self.main_menu.clear() + self.assertEqual(len(self.main_menu.get_items()), 0) + + +class TraySystemIntegrationTest(GraphicUnitTest): + """Test system integration aspects of tray functionality""" + + def setUp(self): + super().setUp() + self._prev_history = LoggerHistory.history[:] + + def tearDown(self): + LoggerHistory.history[:] = self._prev_history + super().tearDown() + + def test_tray_creation_logging(self): + """Test that tray icon creation is properly logged""" + LoggerHistory.clear_history() + + # Try to create tray icon + icon_path = resource_find("data/logo/kivy-icon-32.png") + if not icon_path: + # Create minimal temp file + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + icon_path = f.name + f.write(b"\x89PNG\r\n\x1a\n") + + try: + menu = TrayMenu() + tray_icon = TrayIcon( + icon_path=icon_path, tooltip="Test Tray", menu=menu + ) + + # Check if tray icon creation was attempted + # (Logging behavior may vary by platform) + self.assertIsInstance(tray_icon, TrayIcon) + + finally: + if icon_path and not icon_path.endswith("kivy-icon-32.png"): + if os.path.exists(icon_path): + os.unlink(icon_path) + + def test_platform_tray_support_detection(self): + """Test detection of platform tray support""" + # This test checks if the tray system can be initialized + # without actually creating a visible tray icon + + try: + menu = TrayMenu() + item = TrayMenuItem(label="Test", type="button") + menu.add_item(item) + + # Platform support can be detected indirectly + self.assertIsInstance(menu, TrayMenu) + self.assertEqual(len(menu.get_items()), 1) + + except Exception as e: + # If tray is not supported, it should fail gracefully + self.skipTest(f"Tray not supported on this platform: {e}") + + def test_multiple_tray_icons_handling(self): + """Test handling multiple tray icons""" + icon_path = resource_find("data/logo/kivy-icon-32.png") + if not icon_path: + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + icon_path = f.name + f.write(b"\x89PNG\r\n\x1a\n") + + try: + menu1 = TrayMenu() + menu2 = TrayMenu() + + tray1 = TrayIcon(icon_path=icon_path, tooltip="Tray 1", menu=menu1) + tray2 = TrayIcon(icon_path=icon_path, tooltip="Tray 2", menu=menu2) + + # Both should be created successfully + self.assertIsInstance(tray1, TrayIcon) + self.assertIsInstance(tray2, TrayIcon) + self.assertNotEqual(tray1, tray2) + + finally: + if icon_path and not icon_path.endswith("kivy-icon-32.png"): + if os.path.exists(icon_path): + os.unlink(icon_path) diff --git a/setup.py b/setup.py index 943c93bb68..a35ec018aa 100644 --- a/setup.py +++ b/setup.py @@ -984,7 +984,7 @@ def determine_sdl3(): _extra_args_cpp = {} for source_file in ('core/window/_window_sdl3.pyx', 'core/text/_text_sdl3.pyx', - 'core/tray/_tray_sdl3.pyx', + 'core/system_tray/_system_tray_sdl3.pyx', 'core/audio_output/audio_sdl3.pyx', 'core/clipboard/_clipboard_sdl3.pyx'): From ac23fee0dd300859524f441931d59927f883c029 Mon Sep 17 00:00:00 2001 From: Dexer <73297572+DexerBR@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:11:24 -0300 Subject: [PATCH 4/5] wip --- kivy/core/system_tray/_system_tray_sdl3.pyx | 254 ++++++++++++++++++-- 1 file changed, 234 insertions(+), 20 deletions(-) diff --git a/kivy/core/system_tray/_system_tray_sdl3.pyx b/kivy/core/system_tray/_system_tray_sdl3.pyx index 1ce97fe0a0..4cb6086055 100644 --- a/kivy/core/system_tray/_system_tray_sdl3.pyx +++ b/kivy/core/system_tray/_system_tray_sdl3.pyx @@ -1,3 +1,202 @@ +""" +Kivy System Tray Documentation +============================== + +This module provides system tray functionality for Kivy applications using +the core.system_tray components. + +Classes Overview +---------------- + +The system tray implementation consists of three main classes: + +* :class:`TrayIcon`: Main system tray icon management +* :class:`TrayMenu`: Container for menu items +* :class:`TrayMenuItem`: Individual menu entries + +Basic Usage +----------- + +Creating a Simple System Tray +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from kivy.core.system_tray import TrayIcon, TrayMenu, TrayMenuItem + + # Approach 1: Using internal menu + system_tray = TrayIcon() + system_tray.create() + + main_menu = system_tray.menu + main_menu.add_item(TrayMenuItem(label="File")) + main_menu.add_item(TrayMenuItem(label="Exit")) + +.. code-block:: python + + # Approach 2: External menu creation + main_menu = TrayMenu() + main_menu.add_item(TrayMenuItem(label="File")) + main_menu.add_item(TrayMenuItem(label="Exit")) + + system_tray = TrayIcon(menu=main_menu).create() + +Advanced Features +----------------- + +Creating Submenus +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Create submenu + options_submenu = TrayMenu() + options_submenu.add_item(TrayMenuItem(label="General")) + options_submenu.add_item(TrayMenuItem(label="Security")) + + # Add to main menu + main_menu.add_item( + TrayMenuItem( + label="Options", + type="submenu", + menu=options_submenu + ) + ) + +Menu Separators +~~~~~~~~~~~~~~~ + +.. code-block:: python + + main_menu.add_item(TrayMenuItem(type="separator")) + +Inline Menu Creation +~~~~~~~~~~~~~~~~~~~~ + +For complex menus, you can use inline creation: + +.. code-block:: python + + system_tray = TrayIcon( + menu=TrayMenu( + items=[ + TrayMenuItem(label="File"), + TrayMenuItem(type="separator"), + TrayMenuItem( + label="Options", + type="submenu", + menu=TrayMenu( + items=[ + TrayMenuItem(label="General"), + TrayMenuItem(label="Security"), + ] + ) + ), + ] + ) + ).create() + +Classes Reference +----------------- + +TrayIcon +~~~~~~~~ + +.. class:: TrayIcon(menu=None) + + Main system tray icon manager. + + :param menu: Optional TrayMenu instance + :type menu: TrayMenu or None + + .. method:: create() + + Initialize and display the system tray icon. + + :returns: TrayIcon instance for method chaining + :rtype: TrayIcon + + .. attribute:: menu + + Internal menu reference. Available after create() is called. + + :type: TrayMenu + +TrayMenu +~~~~~~~~ + +.. class:: TrayMenu(items=None) + + Container for menu items. + + :param items: List of TrayMenuItem instances + :type items: list or None + + .. method:: add_item(item) + + Add a menu item to the menu. + + :param item: Menu item to add + :type item: TrayMenuItem + +TrayMenuItem +~~~~~~~~~~~~ + +.. class:: TrayMenuItem(label=None, type="item", menu=None) + + Individual menu entry. + + :param label: Display text for the menu item + :type label: str or None + :param type: Item type ("item", "separator", "submenu") + :type type: str + :param menu: Submenu for submenu items + :type menu: TrayMenu or None + +Best Practices +-------------- + +1. **Menu Structure**: Keep menus simple and intuitive +2. **Separators**: Use separators to group related items +3. **Method Chaining**: TrayIcon.create() returns self for chaining +4. **Memory Management**: Store tray reference to prevent garbage collection + +Examples +-------- + +Complete Application Example +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from kivy.app import App + from kivy.uix.label import Label + from kivy.core.system_tray import TrayIcon, TrayMenu, TrayMenuItem + + class MyApp(App): + def build(self): + # Create system tray + self.setup_tray() + return Label(text="App with System Tray") + + def setup_tray(self): + main_menu = TrayMenu() + main_menu.add_item(TrayMenuItem(label="Show App")) + main_menu.add_item(TrayMenuItem(type="separator")) + main_menu.add_item(TrayMenuItem(label="Exit")) + + self.system_tray = TrayIcon(menu=main_menu).create() + + MyApp().run() + +See Also +-------- + +* Kivy Documentation: https://kivy.org/doc/stable/ +* System Tray Guidelines: Platform-specific implementation notes +""" + + include "../../../kivy/lib/sdl3.pxi" import os @@ -437,17 +636,25 @@ cdef class TrayMenu(_SDLPointerHandler): cdef list __items - def __init__(self) -> None: + def __init__(self, list items = None) -> None: """ - Initialize an empty tray menu. + Initialize a tray menu with optional items. - The menu starts with no items. Use :meth:`add_item` to populate it. + Args: + items: List of TrayMenuItem objects to initialize the menu with. + Defaults to empty list if None. Examples: >>> menu = TrayMenu() >>> print(len(menu.get_items())) # 0 + >>> + >>> items = [TrayMenuItem("File"), TrayMenuItem("Edit")] + >>> menu = TrayMenu(items) + >>> print(len(menu.get_items())) # 2 """ self.__items = [] + if items is not None: + self.add_items(items) cpdef list get_items(self): """ @@ -469,7 +676,17 @@ cdef class TrayMenu(_SDLPointerHandler): >>> print(items[0].label) # "Test" """ return self.__items - + + cpdef void add_items(self, list items): + """ + Add multiple items to the menu at once. + + Args: + items (list): List of TrayMenuItem objects to add + """ + for item in items: + self.add_item(item) + cpdef void add_item(self, TrayMenuItem item, index=-1): """ Add an item to the menu at the specified position. @@ -1025,22 +1242,19 @@ cdef class _TrayIconHandler: # Create and populate the menu if it exists cdef SDL_TrayMenu *sdl_menu cdef TrayMenu menu = tray_icon.menu - tray_icon_menu_items = menu.get_items() - if menu and tray_icon_menu_items: - sdl_menu = SDL_CreateTrayMenu(tray) - - if not sdl_menu: - error = SDL_GetError() - Logger.error(f"SystemTray: Failed to create menu: {error.decode('utf-8', 'replace')}") - SDL_DestroyTray(tray) - SDL_DestroySurface(icon_surface) - return False - - # Store the SDL menu pointer in the Python menu object - menu.set_pointer(sdl_menu) - - # Populate the menu with items - self._populate_menu(sdl_menu, menu) + + sdl_menu = SDL_CreateTrayMenu(tray) + if not sdl_menu: + error = SDL_GetError() + Logger.error(f"SystemTray: Failed to create menu: {error.decode('utf-8', 'replace')}") + SDL_DestroyTray(tray) + SDL_DestroySurface(icon_surface) + return False + + # Store the SDL menu pointer in the Python menu object + menu.set_pointer(sdl_menu) + # Populate the menu with items + self._populate_menu(sdl_menu, menu) Logger.info(f"SystemTray: Successfully created tray icon") From d40daf83d56dc1723cae877830807b1165aad7a4 Mon Sep 17 00:00:00 2001 From: Dexer <73297572+DexerBR@users.noreply.github.com> Date: Wed, 10 Sep 2025 13:45:20 -0300 Subject: [PATCH 5/5] draft for doc --- .gitignore | 1 + doc/autobuild.py | 1 + kivy/core/system_tray/__init__.py | 20 +++++++++++++++++++- kivy/core/system_tray/_system_tray_sdl3.pyx | 6 ++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 380540b3ae..6390d77cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ kivy/graphics/egl_backend/egl_angle.c kivy/core/tray/_tray_sdl3.c kivy/core/image/_img_sdl3.cpp kivy/core/system_tray/_system_tray_sdl3.c +/doc diff --git a/doc/autobuild.py b/doc/autobuild.py index cc994c2231..ab3adbf3dd 100644 --- a/doc/autobuild.py +++ b/doc/autobuild.py @@ -36,6 +36,7 @@ import kivy.core.gl import kivy.core.image import kivy.core.spelling +import kivy.core.system_tray import kivy.core.text import kivy.core.text.markup import kivy.core.video diff --git a/kivy/core/system_tray/__init__.py b/kivy/core/system_tray/__init__.py index 51a58d6627..c80264a42f 100644 --- a/kivy/core/system_tray/__init__.py +++ b/kivy/core/system_tray/__init__.py @@ -1,2 +1,20 @@ -from kivy.core.system_tray._system_tray_sdl3 import * +from kivy.core.system_tray._system_tray_sdl3 import ( + TrayIcon as _CoreTrayIcon, + TrayMenu as _CoreTrayMenu, + TrayMenuItem as _CoreTrayMenuItem, +) +""" +System tray icon with context menu functionality. +Provides integration with the OS notification area, allowing apps to display +an interactive icon with tooltip and customizable menu. +""" + +class TrayIcon(_CoreTrayIcon): + ... + +class TrayMenu(_CoreTrayMenu): + ... + +class TrayMenuItem(_CoreTrayMenuItem): + ... diff --git a/kivy/core/system_tray/_system_tray_sdl3.pyx b/kivy/core/system_tray/_system_tray_sdl3.pyx index 4cb6086055..360a2b4893 100644 --- a/kivy/core/system_tray/_system_tray_sdl3.pyx +++ b/kivy/core/system_tray/_system_tray_sdl3.pyx @@ -2,6 +2,12 @@ Kivy System Tray Documentation ============================== + +.. image:: images/system_tray.png + :align: center + :scale: 80% + + This module provides system tray functionality for Kivy applications using the core.system_tray components.