diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf2266d06a..db7d37fc72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,7 +221,8 @@ jobs: pre-command: | sudo apt update -y sudo apt install -y --no-install-recommends \ - blackbox pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-webkit2-4.1 + blackbox pkg-config python3-dev libgirepository1.0-dev libcairo2-dev \ + gir1.2-webkit2-4.1 gir1.2-xapp-1.0 # Start Virtual X Server echo "Start X server..." @@ -244,7 +245,8 @@ jobs: pre-command: | sudo apt update -y sudo apt install -y --no-install-recommends \ - mutter pkg-config python3-dev libgirepository1.0-dev libcairo2-dev gir1.2-webkit2-4.1 + mutter pkg-config python3-dev libgirepository1.0-dev libcairo2-dev \ + gir1.2-webkit2-4.1 gir1.2-xapp-1.0 # Start Virtual X Server echo "Start X server..." diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index 3c8f522bc5..fb9e496576 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -9,6 +9,7 @@ from .icons import Icon from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet from .widgets.box import Box from .widgets.button import Button from .widgets.canvas import Canvas @@ -51,6 +52,10 @@ def not_implemented(feature): # Hardware "Camera", "Location", + # Status icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # Widgets # ActivityIndicator "Box", diff --git a/android/src/toga_android/statusicons.py b/android/src/toga_android/statusicons.py new file mode 100644 index 0000000000..0578ddef7c --- /dev/null +++ b/android/src/toga_android/statusicons.py @@ -0,0 +1,32 @@ +import toga + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + pass + + def create(self): + toga.NotImplementedWarning.warn("Android", "Status Icons") + + def remove(self): + pass # pragma: no cover + + +class SimpleStatusIcon(StatusIcon): + pass + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + + def create(self): + pass diff --git a/android/tests_backend/app.py b/android/tests_backend/app.py index 0214f92356..2d82b1e7f9 100644 --- a/android/tests_backend/app.py +++ b/android/tests_backend/app.py @@ -1,8 +1,8 @@ from pathlib import Path +import pytest from android import R from org.beeware.android import MainActivity -from pytest import xfail from toga import Group @@ -40,7 +40,7 @@ def logs_path(self): return Path(self.get_app_context().getFilesDir().getPath()) / "log" def assert_app_icon(self, icon): - xfail("Android apps don't have app icons at runtime") + pytest.xfail("Android apps don't have app icons at runtime") def _menu_item(self, path): menu = self.main_window_probe._native_menu() @@ -68,7 +68,7 @@ def _activate_menu_item(self, path): assert self.native.onOptionsItemSelected(self._menu_item(path)) def activate_menu_exit(self): - xfail("This backend doesn't have an exit command") + pytest.xfail("This backend doesn't have an exit command") def activate_menu_about(self): self._activate_menu_item(["About Toga Testbed"]) @@ -79,7 +79,7 @@ async def close_about_dialog(self): await self.press_dialog_button(about_dialog, "OK") def activate_menu_visit_homepage(self): - xfail("This backend doesn't have a visit homepage command") + pytest.xfail("This backend doesn't have a visit homepage command") def assert_menu_item(self, path, *, enabled=True): assert self._menu_item(path).isEnabled() == enabled @@ -102,27 +102,39 @@ def assert_system_menus(self): self.assert_menu_item(["About Toga Testbed"]) def activate_menu_close_window(self): - xfail("This backend doesn't have a window management menu") + pytest.xfail("This backend doesn't have a window management menu") def activate_menu_close_all_windows(self): - xfail("This backend doesn't have a window management menu") + pytest.xfail("This backend doesn't have a window management menu") def activate_menu_minimize(self): - xfail("This backend doesn't have a window management menu") + pytest.xfail("This backend doesn't have a window management menu") def enter_background(self): - xfail( + pytest.xfail( "This is possible (https://stackoverflow.com/a/7071289), but there's no " "easy way to bring it to the foreground again" ) def enter_foreground(self): - xfail("See enter_background") + pytest.xfail("See enter_background") def terminate(self): - xfail("Can't simulate this action without killing the app") + pytest.xfail("Can't simulate this action without killing the app") def rotate(self): self.native.findViewById( R.id.content ).getViewTreeObserver().dispatchOnGlobalLayout() + + def has_status_icon(self, status_icon): + pytest.xfail("Status icons not implemented on Android") + + def status_menu_items(self, status_icon): + pytest.xfail("Status icons not implemented on Android") + + def activate_status_icon_button(self, item_id): + pytest.xfail("Status icons not implemented on Android") + + def activate_status_menu_item(self, item_id, title): + pytest.xfail("Status icons not implemented on Android") diff --git a/changes/97.feature.rst b/changes/97.feature.1.rst similarity index 100% rename from changes/97.feature.rst rename to changes/97.feature.1.rst diff --git a/changes/97.feature.2.rst b/changes/97.feature.2.rst new file mode 100644 index 0000000000..7980b9656f --- /dev/null +++ b/changes/97.feature.2.rst @@ -0,0 +1 @@ +Apps can now add items to the system tray. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index 053dbc351f..b38da486f4 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -15,7 +15,7 @@ from toga.command import Command, Group, Separator from toga.handlers import NativeHandler -from .keys import cocoa_key +from .command import Command as CommandImpl, submenu_for_group from .libs import ( NSAboutPanelOptionApplicationIcon, NSAboutPanelOptionApplicationName, @@ -63,12 +63,12 @@ def application_openFiles_(self, app, filenames) -> None: @objc_method def selectMenuItem_(self, sender) -> None: - cmd = self.impl._menu_items[sender] + cmd = CommandImpl.for_menu_item(sender) cmd.action() @objc_method def validateMenuItem_(self, sender) -> bool: - cmd = self.impl._menu_items[sender] + cmd = CommandImpl.for_menu_item(sender) return cmd.enabled @@ -104,8 +104,7 @@ def __init__(self, interface): self.appDelegate.native = self.native self.native.setDelegate(self.appDelegate) - # Create the lookup table for menu items - self._menu_groups = {} + # Create the lookup table for commands and menu items self._menu_items = {} # Call user code to populate the main window @@ -250,81 +249,30 @@ def create_standard_commands(self): ), ) - def _submenu(self, group, menubar): - """Obtain the submenu representing the command group. - - This will create the submenu if it doesn't exist. It will call itself - recursively to build the full path to menus inside submenus, returning the - "leaf" node in the submenu path. Once created, it caches the menu that has been - created for future lookup. - """ - try: - return self._menu_groups[group] - except KeyError: - if group is None: - submenu = menubar - else: - parent_menu = self._submenu(group.parent, menubar) - - menu_item = parent_menu.addItemWithTitle( - group.text, action=None, keyEquivalent="" - ) - submenu = NSMenu.alloc().initWithTitle(group.text) - parent_menu.setSubmenu(submenu, forItem=menu_item) - - # Install the item in the group cache. - self._menu_groups[group] = submenu - return submenu - def create_menus(self): # Recreate the menu. # Remove any native references to the existing menu for menu_item, cmd in self._menu_items.items(): - cmd._impl.native.remove(menu_item) + cmd._impl.remove_menu_item(menu_item) # Create a clean menubar instance. menubar = NSMenu.alloc().initWithTitle("MainMenu") submenu = None - self._menu_groups = {} + + # Warm the menu group cache with the root menubar + group_cache = {None: menubar} self._menu_items = {} for cmd in self.interface.commands: - submenu = self._submenu(cmd.group, menubar) + submenu = submenu_for_group(cmd.group, group_cache) + if isinstance(cmd, Separator): - submenu.addItem(NSMenuItem.separatorItem()) + menu_item = NSMenuItem.separatorItem() else: - if cmd.shortcut: - key, modifier = cocoa_key(cmd.shortcut) - else: - key = "" - modifier = None - - # Native handlers can be invoked directly as menu actions. - # Standard wrapped menu items have a `_raw` attribute, - # and are invoked using the selectMenuItem: - if hasattr(cmd.action, "_raw"): - action = SEL("selectMenuItem:") - else: - action = cmd.action - - item = NSMenuItem.alloc().initWithTitle( - cmd.text, - action=action, - keyEquivalent=key, - ) - - if modifier is not None: - item.keyEquivalentModifierMask = modifier - - # Explicit set the initial enabled/disabled state on the menu item - item.setEnabled(cmd.enabled) - - # Associated the MenuItem with the command, so that future - # changes to enabled etc are reflected. - cmd._impl.native.add(item) - - self._menu_items[item] = cmd - submenu.addItem(item) + menu_item = cmd._impl.create_menu_item() + self._menu_items[menu_item] = cmd + + submenu.addItem(menu_item) # Set the menu for the app. self.native.mainMenu = menubar diff --git a/cocoa/src/toga_cocoa/command.py b/cocoa/src/toga_cocoa/command.py index a5598d5cf4..8559749c50 100644 --- a/cocoa/src/toga_cocoa/command.py +++ b/cocoa/src/toga_cocoa/command.py @@ -1,14 +1,58 @@ import sys +from rubicon.objc import SEL + from toga import Command as StandardCommand, Group, Key -from toga_cocoa.libs import NSMenuItem +from toga_cocoa.keys import cocoa_key +from toga_cocoa.libs import NSMenu, NSMenuItem + + +def submenu_for_group(group, group_cache): + """Obtain the submenu representing the command group. + + This will create the submenu if it doesn't exist. It will call itself recursively to + build the full path to menus inside submenus, returning the "leaf" node in the + submenu path. Once created, it caches the menu that has been created for future + lookup. + + This method assumes that the top-level item (for group=None) exists in the + group_cache. If it doesn't, a ValueError is raised when the top level group is + requested. + + :param group: The group to turn into a submenu. + :param group_cache: The cache of existing groups. + :raises ValueError: If the top level group cannot be found in the group cache. + """ + try: + return group_cache[group] + except KeyError: + if group is None: + raise ValueError("Cannot find top level group") + else: + parent_menu = submenu_for_group(group.parent, group_cache) + + menu_item = parent_menu.addItemWithTitle( + group.text, action=None, keyEquivalent="" + ) + submenu = NSMenu.alloc().initWithTitle(group.text) + parent_menu.setSubmenu(submenu, forItem=menu_item) + + # Install the item in the group cache. + group_cache[group] = submenu + return submenu class Command: + menu_items = {} + def __init__(self, interface): self.interface = interface self.native = set() + @classmethod + def for_menu_item(cls, item): + return cls.menu_items[item] + @classmethod def standard(cls, app, id): # ---- App menu ----------------------------------- @@ -94,3 +138,43 @@ def set_enabled(self, value): # Otherwise, assume the native object has # and explicit enabled property item.setEnabled(value) + + def create_menu_item(self): + if self.interface.shortcut: + key, modifier = cocoa_key(self.interface.shortcut) + else: + key = "" + modifier = None + + # Native handlers can be invoked directly as menu actions. + # Standard wrapped menu items have a `_raw` attribute, + # and are invoked using the selectMenuItem: + if hasattr(self.interface.action, "_raw"): + action = SEL("selectMenuItem:") + else: + action = self.interface.action + + item = NSMenuItem.alloc().initWithTitle( + self.interface.text, + action=action, + keyEquivalent=key, + ) + + if modifier is not None: + item.keyEquivalentModifierMask = modifier + + # Explicit set the initial enabled/disabled state on the menu item + item.setEnabled(self.interface.enabled) + + # Add the NSMenuItem instance as a native representation of this command. + self.native.add(item) + + # Add the menu item instance to the instance map. + self.menu_items[item] = self.interface + + return item + + def remove_menu_item(self, menu_item): + menu_item.menu.removeItem(menu_item) + self.native.remove(menu_item) + self.menu_items.pop(menu_item) diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index ffe7968543..3e951d3b17 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/factory.py @@ -9,6 +9,7 @@ from .icons import Icon from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet # Widgets from .widgets.activityindicator import ActivityIndicator @@ -54,6 +55,10 @@ def not_implemented(feature): # Hardware "Camera", "Location", + # Status Icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # Widgets "ActivityIndicator", "Box", diff --git a/cocoa/src/toga_cocoa/libs/appkit.py b/cocoa/src/toga_cocoa/libs/appkit.py index 3d81abf368..2e656e8b45 100644 --- a/cocoa/src/toga_cocoa/libs/appkit.py +++ b/cocoa/src/toga_cocoa/libs/appkit.py @@ -609,6 +609,13 @@ class NSLineBreakMode(Enum): # NSSplitView.h NSSplitView = ObjCClass("NSSplitView") +###################################################################### +# NSStatusBar.h +NSStatusBar = ObjCClass("NSStatusBar") + +NSVariableStatusItemLength = -1.0 +NSSquareStatusItemLength = -2.0 + ###################################################################### # NSStepper.h NSStepper = ObjCClass("NSStepper") diff --git a/cocoa/src/toga_cocoa/statusicons.py b/cocoa/src/toga_cocoa/statusicons.py new file mode 100644 index 0000000000..3327c38139 --- /dev/null +++ b/cocoa/src/toga_cocoa/statusicons.py @@ -0,0 +1,115 @@ +from rubicon.objc import ( + SEL, + NSObject, + NSSize, + objc_method, + objc_property, +) + +import toga +from toga.command import Group, Separator + +from .command import submenu_for_group +from .libs import NSMenu, NSMenuItem, NSSquareStatusItemLength, NSStatusBar + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + if self.native: + native_icon = icon._impl.native if icon else toga.App.app.icon._impl.native + + # macOS status icons need to be 22px square, or they render weird + status_icon = native_icon.copy() + status_icon.setSize(NSSize(22, 22)) + self.native.button.image = status_icon + + def create(self): + self.native = NSStatusBar.systemStatusBar.statusItemWithLength( + NSSquareStatusItemLength + ).retain() + self.native.button.toolTip = self.interface.text + self.set_icon(self.interface.icon) + + def remove(self): + NSStatusBar.systemStatusBar.removeStatusItem(self.native) + self.native.release() + self.native = None + + +class StatusItemButtonDelegate(NSObject): + interface = objc_property(object, weak=True) + + @objc_method + def onPress_(self, sender) -> None: + self.interface.on_press() + + +class SimpleStatusIcon(StatusIcon): + def __init__(self, interface): + super().__init__(interface) + self.delegate = StatusItemButtonDelegate.alloc().init() + self.delegate.interface = interface + + def create(self): + super().create() + self.native.button.action = SEL("onPress:") + self.native.button.target = self.delegate + + +class MenuStatusIcon(StatusIcon): + def create(self): + super().create() + self.create_menu() + + def create_menu(self): + submenu = NSMenu.alloc().init() + self.native.menu = submenu + return submenu + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + self._menu_items = {} + + def create(self): + # Menu status icons are the only icons that have extra construction needs. + # Clear existing menu items + for menu_item, cmd in self._menu_items.items(): + cmd._impl.remove_menu_item(menu_item) + + # Determine the primary status icon. + primary_group = self.interface._primary_menu_status_icon + if primary_group is None: # pragma: no cover + # If there isn't at least one menu status icon, then there aren't any menus + # to populate. This can't happen in the testbed, so it's marked nocover. + return + + # Recreate the menus for the menu status icons + group_cache = { + item: item._impl.create_menu() for item in self.interface._menu_status_icons + } + # Map the COMMANDS group to the primary status icon's menu. + group_cache[Group.COMMANDS] = primary_group._impl.native.menu + self._menu_items = {} + + for cmd in self.interface.commands: + try: + submenu = submenu_for_group(cmd.group, group_cache) + except ValueError: + raise ValueError( + f"Command {cmd.text!r} does not belong to " + "a current status icon group." + ) + else: + if isinstance(cmd, Separator): + menu_item = NSMenuItem.separatorItem() + else: + menu_item = cmd._impl.create_menu_item() + self._menu_items[menu_item] = cmd + + submenu.addItem(menu_item) diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 125415bdc7..96bca98634 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -313,3 +313,33 @@ def open_document_by_drag(self, document_path): self.app._impl.native, openFiles=[str(document_path)], ) + + def has_status_icon(self, status_icon): + return status_icon._impl.native is not None + + def status_menu_items(self, status_icon): + if status_icon._impl.native.menu: + return [ + { + "": "---", + "About Toga Testbed": "**ABOUT**", + "Quit Toga Testbed": "**EXIT**", + }.get(str(item.title), str(item.title)) + for item in status_icon._impl.native.menu.itemArray + ] + else: + # It's a button status item + return None + + def activate_status_icon_button(self, item_id): + self.app.status_icons[item_id]._impl.native.button.performClick(None) + + def activate_status_menu_item(self, item_id, title): + item = self.app.status_icons[item_id]._impl.native.menu.itemWithTitle(title) + send_message( + self.app._impl.native.delegate, + item.action, + item, + restype=None, + argtypes=[objc_id], + ) diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index 0a950d8dcd..2f7679bd88 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -21,6 +21,7 @@ from .icons import Icon from .images import Image from .keys import Key +from .statusicons import MenuStatusIcon, SimpleStatusIcon from .types import LatLng, Position, Size from .widgets.activityindicator import ActivityIndicator from .widgets.base import Widget @@ -91,6 +92,9 @@ def warn(cls, platform: str, feature: str) -> None: "Font", "Icon", "Image", + # Status icons + "MenuStatusIcon", + "SimpleStatusIcon", # Types "LatLng", "Position", diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 4dea899619..db80be2970 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -21,6 +21,7 @@ from toga.paths import Paths from toga.platform import get_platform_factory from toga.screens import Screen +from toga.statusicons import StatusIconSet from toga.widgets.base import Widget from toga.window import MainWindow, Window, WindowSet @@ -337,6 +338,7 @@ def __init__( # We need the command set to exist so that startup et al. can add commands; # but we don't have an impl yet, so we can't set the on_change handler self._commands = CommandSet() + self._status_icons = StatusIconSet() self._startup_method = startup @@ -609,6 +611,10 @@ def _startup(self) -> None: self._create_standard_commands() self._impl.create_standard_commands() + # Install the standard status icon commands. Again, this is done *before* startup + # so that the user's code can remove/change the defaults. + self.status_icons._create_standard_commands() + # Invoke the user's startup method (or the default implementation) self.startup() @@ -626,6 +632,11 @@ def _startup(self) -> None: self._impl.create_menus() self.commands.on_change = self._impl.create_menus + # Manifest the initial state of the status icons, then install an on-change + # handler so that any future changes will be reflected in the GUI. + self.status_icons._impl.create() + self.status_icons.commands.on_change = self.status_icons._impl.create + # Manifest the initial state of toolbars (on the windows that have # them), then install a change listener so that any future changes to # the toolbar cause a change in toolbar items. @@ -704,6 +715,11 @@ def screens(self) -> list[Screen]: """Returns a list of available screens.""" return [screen.interface for screen in self._impl.get_screens()] + @property + def status_icons(self) -> StatusIconSet: + """The status icons displayed by the app.""" + return self._status_icons + @property def widgets(self) -> WidgetRegistry: """The widgets managed by the app, over all windows. diff --git a/core/src/toga/command.py b/core/src/toga/command.py index 20fc0283b7..744ff2df55 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -12,6 +12,8 @@ from toga.app import App from toga.icons import IconContentT +_py_id = id + class Group: def __init__( @@ -21,6 +23,7 @@ def __init__( parent: Group | None = None, section: int = 0, order: int = 0, + id: str | None = None, ): """ A collection of commands to display together. @@ -32,8 +35,10 @@ def __init__( :param order: The position where the group should appear within its section. If multiple items have the same group, section and order, they will be sorted alphabetically by their text. + :param id: A unique identifier for the group. """ - self.text = text + self._id = f"group-{_py_id(self)}" if id is None else id + self._text = text self.order = order if parent is None and section != 0: raise ValueError("Section cannot be set without parent group") @@ -44,6 +49,16 @@ def __init__( self._parent: Group | None = None self.parent = parent + @property + def id(self) -> str: + """A unique identifier for the group.""" + return self._id + + @property + def text(self) -> str: + """A text label for the group.""" + return self._text + @property def parent(self) -> Group | None: """The parent of this group; returns ``None`` if the group is a root group.""" @@ -234,7 +249,7 @@ def __init__( :param enabled: Is the Command currently enabled? :param id: A unique identifier for the command. """ - self._id = f"cmd-{hash(self)}" if id is None else id + self._id = f"cmd-{_py_id(self)}" if id is None else id self.text = text self.shortcut = shortcut diff --git a/core/src/toga/statusicons.py b/core/src/toga/statusicons.py new file mode 100644 index 0000000000..97e8168473 --- /dev/null +++ b/core/src/toga/statusicons.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import sys +from abc import abstractmethod +from collections.abc import Iterator +from typing import TYPE_CHECKING, Mapping, Sequence + +import toga +from toga.command import Command, CommandSet, Group +from toga.handlers import wrapped_handler +from toga.icons import Icon +from toga.platform import get_platform_factory + +if TYPE_CHECKING: + from toga.icons import IconContentT + +_py_id = id + + +class StatusIcon: + def __init__(self, icon: IconContentT | None = None): + """An abstract base class for all status icons.""" + self.factory = get_platform_factory() + self._impl = getattr(self.factory, self.__class__.__name__)(interface=self) + + self.icon = icon + + @property + @abstractmethod + def id(self) -> str: + """A unique identifier for the status icon.""" + + @property + @abstractmethod + def text(self) -> str: + """A text label for the status icon.""" + + @property + def icon(self) -> Icon | None: + """The Icon to display in the status bar. + + When setting the icon, you can provide either an :any:`Icon` instance, or a + path that will be passed to the ``Icon`` constructor. + """ + return self._icon + + @icon.setter + def icon(self, icon_or_name: IconContentT | None): + if isinstance(icon_or_name, Icon) or icon_or_name is None: + self._icon = icon_or_name + else: + self._icon = Icon(icon_or_name) + + self._impl.set_icon(self._icon) + + +class SimpleStatusIcon(StatusIcon): + def __init__( + self, + id: str | None = None, + icon: IconContentT | None = None, + text: str | None = None, + on_press: toga.widgets.button.OnPressHandler | None = None, + ): + """ + An button in a status bar or system tray. + + When pressed, the ``on_press`` handler will be activated. + + :param id: An identifier for the status icon. + :param icon: The icon, or icon resource, that will be displayed in the status + bar or system tray. + :param text: A text label for the status icon. Defaults to the formal name of + the app. + :param on_press: The handler to invoke when the status icon is pressed. + """ + super().__init__(icon=icon) + self.on_press = on_press + + self._id = f"statusicon-{_py_id(self)}" if id is None else id + self._text = text if text is not None else toga.App.app.formal_name + + def __repr__(self): + return f"" + + @property + def id(self) -> str: + return self._id + + @property + def text(self) -> str: + return self._text + + @property + def on_press(self) -> toga.widgets.button.OnPressHandler: + """The handler to invoke when the status icon is pressed.""" + return self._on_press + + @on_press.setter + def on_press(self, handler: toga.widgets.button.OnPressHandler) -> None: + self._on_press = wrapped_handler(self, handler) + + +class MenuStatusIcon(Group, StatusIcon): + def __init__( + self, + id: str | None = None, + icon: IconContentT | None = None, + text: str | None = None, + ): + """ + An item in a status bar or system tray that displays a menu when pressed. + + A ``MenuStatusIcon`` can be used as a :class:`~toga.Group` when defining + :class:`toga.Command` instances. + + :param id: An identifier for the status icon. + :param icon: The icon, or icon resource, that will be displayed in the status + bar or system tray. + :param text: A text label for the status icon. Defaults to the formal name of + the app. + """ + Group.__init__( + self, + id=f"menustatusitem-{_py_id(self)}" if id is None else id, + text=(text if text is not None else toga.App.app.formal_name), + ) + StatusIcon.__init__(self, icon=icon) + + def __repr__(self): + return f"" + + +class StatusIconSet(Sequence[StatusIcon], Mapping[str, StatusIcon]): + def __init__(self): + """An ordered collection of status icons. + + The items in the set can be retrieved by instance, or by ID. When iterated, the + items are returned in the order they were added. + """ + self.factory = get_platform_factory() + self._impl = self.factory.StatusIconSet(interface=self) + + self.elements: dict[str, StatusIcon] = {} + self.commands = CommandSet() + + @property + def _menu_status_icons(self): + """An iterator over the menu status icons that have been registered.""" + return (icon for icon in self if isinstance(icon, MenuStatusIcon)) + + @property + def _primary_menu_status_icon(self): + """The first menu status icon that has been registered. + + Returns ``None`` if no menu status icons have been registered. + """ + try: + return next(self._menu_status_icons) + except StopIteration: + # No menu status icons registered. + return None + + def _create_standard_commands(self): + # Create the standard commands for the menu status icon. Use the standard + # constructor, but force the commands into *last* section of the COMMANDS group + # so they'll appear on the first MenuStatusIcon. + for cmd_id in [ + Command.ABOUT, + Command.EXIT, + ]: + self.commands.add( + Command.standard( + toga.App.app, + cmd_id, + section=sys.maxsize, + group=Group.COMMANDS, + ) + ) + + def __iter__(self) -> Iterator[StatusIcon]: + return iter(self.elements.values()) + + def __contains__(self, value: object) -> bool: + if isinstance(value, str): + return value in self.elements.keys() + else: + return value in self.elements.values() + + def __len__(self) -> int: + return len(self.elements) + + def __getitem__(self, index_or_id): + if isinstance(index_or_id, int): + return list(self.elements.values())[index_or_id] + else: + return self.elements[index_or_id] + + def add(self, *status_icons: StatusIcon): + """Add one or more icons to the set. + + :param status_icons: The icon (or icons) to add to the set. + """ + added = False + for status_icon in status_icons: + if status_icon.id not in self.elements: + self.elements[status_icon.id] = status_icon + status_icon._impl.create() + added = True + + if added and self.commands.on_change: + self.commands.on_change() + + def remove(self, status_icon: StatusIcon): + """Remove a single icon from the set. + + :param status_icon: The status icon instance to remove. + :raises ValueError: If the status icon commands include any commands that + reference the icon that has been removed. + """ + try: + self.elements.pop(status_icon.id) + status_icon._impl.remove() + + if self.commands.on_change: + self.commands.on_change() + except KeyError: + raise ValueError("Not a known status icon.") + + def clear(self): + """Remove all the icons from the set. + + :raises ValueError: If the status icon commands include any commands that + reference an icon that has been removed. + """ + # Convert into a list so that we're not deleting from a list while iterating. + for status_icon in list(self): + self.elements.pop(status_icon.id) + status_icon._impl.remove() + + if self.commands.on_change: + self.commands.on_change() diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 7b0914c774..b2a205ff73 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -549,6 +549,7 @@ def startup_assertions(app): assert_action_performed(app, "create App menus") assert_action_performed(app.main_window, "create Window menus") assert_action_performed(app.main_window, "create toolbar") + assert_action_performed(app.status_icons, "create status icons") # 2 menu items have been created assert len(app.commands) == 2 @@ -580,6 +581,7 @@ def startup(self): assert_action_performed(app, "create App menus") assert_action_performed(app.main_window, "create Window menus") assert_action_performed(app.main_window, "create toolbar") + assert_action_performed(app.status_icons, "create status icons") # 3 menu items have been created assert app._impl.n_menu_items == 3 diff --git a/core/tests/statusicons/__init__.py b/core/tests/statusicons/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/tests/statusicons/test_menustatusicon.py b/core/tests/statusicons/test_menustatusicon.py new file mode 100644 index 0000000000..6189c8979e --- /dev/null +++ b/core/tests/statusicons/test_menustatusicon.py @@ -0,0 +1,66 @@ +from pathlib import Path + +import pytest + +import toga +from toga import MenuStatusIcon +from toga_dummy.utils import assert_action_not_performed, assert_action_performed + + +def test_create(app): + """A group can be created with defaults.""" + status_icon = MenuStatusIcon() + + # StatusIcon properties + assert status_icon.icon is None + + # Group properties + assert status_icon.text == "Test App" + assert status_icon.id.startswith("menustatusitem-") + assert status_icon.order == 0 + assert status_icon.section == 0 + assert status_icon.parent is None + + assert repr(status_icon).startswith("" + ) + + # Group properties + assert status_icon.text == "My MenuStatusIcon" + assert status_icon.id == "my-menustatusicon" + assert status_icon.order == 0 + assert status_icon.section == 0 + assert status_icon.parent is None + + # Status icon wasn't created as a result of being instantiated + assert_action_not_performed(status_icon, "create") + # ... but an icon was set. + assert_action_performed(status_icon, "set icon") diff --git a/core/tests/statusicons/test_simplestatusicon.py b/core/tests/statusicons/test_simplestatusicon.py new file mode 100644 index 0000000000..9b3cd5ecd6 --- /dev/null +++ b/core/tests/statusicons/test_simplestatusicon.py @@ -0,0 +1,57 @@ +from pathlib import Path +from unittest.mock import Mock + +import pytest + +import toga +from toga import SimpleStatusIcon +from toga_dummy.utils import assert_action_not_performed, assert_action_performed + + +def test_create(app): + """A group can be created with defaults.""" + status_icon = SimpleStatusIcon() + + assert status_icon.id.startswith("statusicon-") + assert status_icon.icon is None + assert status_icon.text == "Test App" + assert status_icon.on_press._raw is None + + assert repr(status_icon).startswith("" + + # Status icon wasn't created as a result of being instantiated + assert_action_not_performed(status_icon, "create") + # ... but an icon was set. + assert_action_performed(status_icon, "set icon") diff --git a/core/tests/statusicons/test_statusiconset.py b/core/tests/statusicons/test_statusiconset.py new file mode 100644 index 0000000000..cdb45106dd --- /dev/null +++ b/core/tests/statusicons/test_statusiconset.py @@ -0,0 +1,278 @@ +from unittest.mock import Mock + +import pytest + +from toga.command import Command +from toga.statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet +from toga_dummy.utils import ( + EventLog, + assert_action_not_performed, + assert_action_performed, +) + + +@pytest.fixture +def change_handler(): + return Mock() + + +@pytest.fixture +def statusiconset(app): + return StatusIconSet() + + +def test_create(app): + """A StatusIconSet can be created with defaults.""" + statusiconset = StatusIconSet() + + assert len(statusiconset) == 0 + assert list(statusiconset) == [] + assert statusiconset._primary_menu_status_icon is None + assert list(statusiconset._menu_status_icons) == [] + + assert len(statusiconset.commands) == 0 + assert statusiconset.commands.on_change is None + + # Create the standard commands on the StatusIconSet + statusiconset._create_standard_commands() + + assert len(statusiconset.commands) == 2 + assert Command.ABOUT in statusiconset.commands + assert Command.EXIT in statusiconset.commands + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_add_single(statusiconset, change_handler): + """A single icon can be added to a StatusIconSet.""" + if change_handler: + statusiconset.commands.on_change = change_handler + + status_1 = SimpleStatusIcon(id="s1") + statusiconset.add(status_1) + + assert len(statusiconset) == 1 + assert list(statusiconset) == [status_1] + + assert statusiconset["s1"] == status_1 + assert statusiconset[0] == status_1 + assert statusiconset[-1] == status_1 + + assert "s1" in statusiconset + assert "sX" not in statusiconset + + assert status_1 in statusiconset + + assert_action_performed(status_1, "create") + + if change_handler: + change_handler.assert_called_once_with() + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_add_multiple(statusiconset, change_handler): + """Multiple icons can be added to a StatusIconSet.""" + if change_handler: + statusiconset.commands.on_change = change_handler + + status_1 = SimpleStatusIcon(id="s1") + status_2 = SimpleStatusIcon(id="s2") + status_3 = SimpleStatusIcon(id="s3") + statusiconset.add(status_1, status_2, status_3) + + assert len(statusiconset) == 3 + assert list(statusiconset) == [status_1, status_2, status_3] + + assert statusiconset["s1"] == status_1 + assert statusiconset["s2"] == status_2 + assert statusiconset["s3"] == status_3 + + assert statusiconset[0] == status_1 + assert statusiconset[-1] == status_3 + assert statusiconset[1] == status_2 + assert statusiconset[2] == status_3 + + assert "s1" in statusiconset + assert "s2" in statusiconset + assert "s3" in statusiconset + assert "sX" not in statusiconset + + assert status_1 in statusiconset + assert status_2 in statusiconset + assert status_3 in statusiconset + + assert_action_performed(status_1, "create") + assert_action_performed(status_2, "create") + assert_action_performed(status_3, "create") + + if change_handler: + change_handler.assert_called_once_with() + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_add_existing(statusiconset, change_handler): + """An existing item cannot be re-added to a StatusIconSet.""" + if change_handler: + statusiconset.commands.on_change = change_handler + + status_1 = SimpleStatusIcon(id="s1") + status_2 = SimpleStatusIcon(id="s2") + status_3 = SimpleStatusIcon(id="s3") + statusiconset.add(status_1, status_2, status_3) + + assert len(statusiconset) == 3 + assert list(statusiconset) == [status_1, status_2, status_3] + assert "s2" in statusiconset + + EventLog.reset() + if change_handler: + change_handler.reset_mock() + + # Re-adding a status icon is a no-op + statusiconset.add(status_2) + + assert len(statusiconset) == 3 + assert list(statusiconset) == [status_1, status_2, status_3] + assert "s2" in statusiconset + + assert_action_not_performed(status_2, "create") + + if change_handler: + change_handler.assert_not_called() + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_empty_add(statusiconset, change_handler): + """An empty add is a no-op.""" + if change_handler: + statusiconset.commands.on_change = change_handler + + statusiconset.add() + assert len(statusiconset) == 0 + assert list(statusiconset) == [] + + if change_handler: + change_handler.assert_not_called() + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_remove(statusiconset, change_handler): + """An icon can be removed from a statusiconset.""" + if change_handler: + statusiconset.commands.on_change = change_handler + + status_1 = SimpleStatusIcon(id="s1") + status_2 = SimpleStatusIcon(id="s2") + status_3 = SimpleStatusIcon(id="s3") + statusiconset.add(status_1, status_2, status_3) + + assert list(statusiconset) == [status_1, status_2, status_3] + assert len(statusiconset) == 3 + + EventLog.reset() + if change_handler: + change_handler.reset_mock() + + statusiconset.remove(status_2) + + assert list(statusiconset) == [status_1, status_3] + assert len(statusiconset) == 2 + + assert statusiconset[0] == status_1 + assert statusiconset[-1] == status_3 + assert statusiconset[1] == status_3 + + assert "s1" in statusiconset + assert "s2" not in statusiconset + assert "s3" in statusiconset + assert "sX" not in statusiconset + + assert status_1 in statusiconset + assert status_2 not in statusiconset + assert status_3 in statusiconset + + if change_handler: + change_handler.assert_called_once_with() + + assert_action_performed(status_2, "remove") + EventLog.reset() + if change_handler: + change_handler.reset_mock() + + # Removing the same item a second time is an error + with pytest.raises(ValueError, match=r"Not a known status icon."): + statusiconset.remove(status_2) + + assert_action_not_performed(status_2, "remove") + + if change_handler: + change_handler.assert_not_called() + + +@pytest.mark.parametrize("change_handler", [(None), (Mock())]) +def test_clear(statusiconset, change_handler): + """The statusiconset can be cleared.""" + if change_handler: + statusiconset.commands.on_change = change_handler + + status_1 = SimpleStatusIcon(id="s1") + status_2 = SimpleStatusIcon(id="s2") + status_3 = SimpleStatusIcon(id="s3") + statusiconset.add(status_1, status_2, status_3) + + assert list(statusiconset) == [status_1, status_2, status_3] + assert len(statusiconset) == 3 + + EventLog.reset() + if change_handler: + change_handler.reset_mock() + + statusiconset.clear() + assert len(statusiconset) == 0 + assert list(statusiconset) == [] + + assert "s1" not in statusiconset + assert "s2" not in statusiconset + assert "s3" not in statusiconset + + assert status_1 not in statusiconset + assert status_2 not in statusiconset + assert status_3 not in statusiconset + + if change_handler: + change_handler.assert_called_once_with() + + +def test_menu_status_items(statusiconset): + """Menu status items can be differentiated from non-menu items.""" + # There are initially no menu status icons + assert list(statusiconset._menu_status_icons) == [] + assert statusiconset._primary_menu_status_icon is None + + # When the list *only* contains non-menu status icons, nothing is returned + status_1 = SimpleStatusIcon(id="s1") + status_2 = SimpleStatusIcon(id="s2") + status_3 = SimpleStatusIcon(id="s3") + statusiconset.add(status_1, status_2, status_3) + + assert list(statusiconset._menu_status_icons) == [] + assert statusiconset._primary_menu_status_icon is None + + # When there is a menu status item, the can be filtered out. + menu_status_1 = MenuStatusIcon(id="m1") + status_4 = SimpleStatusIcon(id="s4") + menu_status_2 = MenuStatusIcon(id="m2") + menu_status_3 = MenuStatusIcon(id="m3") + statusiconset.add(menu_status_1, status_4, menu_status_2, menu_status_3) + + assert list(statusiconset._menu_status_icons) == [ + menu_status_1, + menu_status_2, + menu_status_3, + ] + assert statusiconset._primary_menu_status_icon is menu_status_1 + + # If a menu status item is removed, others take their place. + statusiconset.remove(menu_status_1) + + assert list(statusiconset._menu_status_icons) == [menu_status_2, menu_status_3] + assert statusiconset._primary_menu_status_icon is menu_status_2 diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 7c05fc79fc..b20cd8a8bd 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -87,6 +87,8 @@ Resources :doc:`Icon ` An icon for buttons, menus, etc :doc:`Image ` An image :doc:`Source ` A base class for data source implementations. + :doc:`Status Icons ` Icons that appear in the system tray for representing app status + while the app isn't visible. :doc:`ListSource ` A data source describing an ordered list of data. :doc:`TreeSource ` A data source describing an ordered hierarchical tree of data. :doc:`ValueSource ` A data source describing a single value. diff --git a/docs/reference/api/resources/index.rst b/docs/reference/api/resources/index.rst index ae8e3a57b3..d8c74fbd48 100644 --- a/docs/reference/api/resources/index.rst +++ b/docs/reference/api/resources/index.rst @@ -14,4 +14,5 @@ Resources sources/list_source sources/tree_source sources/value_source + statusicons validators diff --git a/docs/reference/api/resources/statusicons.rst b/docs/reference/api/resources/statusicons.rst new file mode 100644 index 0000000000..b0dd4ff254 --- /dev/null +++ b/docs/reference/api/resources/statusicons.rst @@ -0,0 +1,187 @@ +.. _statusicons: + +Status Icons +============ + +Icons that appear in the system tray for representing app status while the app isn't visible. + + +.. tabs:: + + .. group-tab:: macOS + + .. figure:: /reference/images/statusicons-cocoa.png + :align: center + :width: 150px + + .. group-tab:: Linux + + .. figure:: /reference/images/statusicons-gtk.png + :align: center + :width: 150px + + .. group-tab:: Windows + + .. figure:: /reference/images/statusicons-winforms.png + :align: center + :width: 150px + + .. group-tab:: Android |no| + + Not supported + + .. group-tab:: iOS |no| + + Not supported + + .. group-tab:: Web |no| + + Not supported + + .. group-tab:: Textual |no| + + Not supported + +Usage +----- + +Although the usual user interface for an app is a window, some apps will augment - or +even replace - a window-base interface with an icon in the system tray or status bar +provided by the operating system. This is especially common for apps that primarily run +in the background. + +Toga supports two types of status icons - simple status icons, and menu status icons. + +Simple status icons +~~~~~~~~~~~~~~~~~~~ + +A simple status icon is a bare icon in the status bar. You can set and change the icon +as required to reflect changes in application state; by default, the status icon will +use the app's icon. The text associated with the icon will be used as a tooltip; again, +the app's formal name will be used as default text. The icon can respond to mouse clicks +by defining an ``on_press`` handler. + +To define a simple status icon, declare an instance of :class:`toga.SimpleStatusIcon`, +and add it to your app's :attr:`~toga.App.status_icons` set: + +.. code-block:: python + + import toga + + # Define a status icon that uses default values for icon and tooltip, + # and doesn't respond to mouse clicks. + status_icon_1 = toga.SimpleStatusIcon() + + # Define a second status icon that provides explicit values for the id, icon and + # tooltip, and responds to mouse clicks. + def my_handler(widget, **kwargs): + print("Second status icon pressed!") + + status_icon_2 = toga.SimpleStatusIcon( + id="second", + text="Hello!", + icon="icons/red", + on_press=my_handler + ) + + # Add both status icons to the app + app.status_icons.add(status_icon_1, status_icon_2) + +Once a status icon has been added to the app, it can be retrieved by ID or by index; and +it can be removed from the app: + +.. code-block:: python + + # Change the icon of the first status icon, retrieved by index: + app.status_icons[0].icon = "icons/green" + + # Change the icon of the second status icon, retrieved by id: + app.status_icons["second"].icon = "icons/blue" + + # Remove the first status icon from the app: + app.status_icons.remove(status_icon_1) + +Menu status icons +~~~~~~~~~~~~~~~~~ + +A menu-based status icon is defined by adding a :class:`toga.MenuStatusIcon` instance. A +:class:`toga.MenuStatusIcon` behaves almost the same as :class:`~toga.SimpleStatusIcon`, +except that it *cannot* have an ``on_click`` handler - but it *can* be used to register +Commands that will be displayed in a menu when the icon is clicked. + +The :class:`~toga.MenuStatusIcon` is a :class:`~toga.Group` for command sorting +purposes. To put a command in a menu associated with a :class:`~toga.MenuStatusIcon`, +set the ``group`` associated with that command, then add the command to the +:class:`~toga.command.CommandSet` associated with status icons. The following example +will create a :class:`~toga.MenuStatusIcon` that has a single top-level menu item, plus +a sub-menu that itself has a single menu item: + +.. code-block:: python + + # Create a MenuStatusIcon + status_icon = toga.MenuStatusIcon(icon="icons/blue") + + # Create some commands that are associated with the menu status icon's group. + def callback(sender, **kwargs): + print("Command activated") + + cmd1 = toga.Command( + callback, + text='Example command', + group=status_icon, + ) + + # Create a sub-group of the status icon. This will appear as a submenu. + stuff_group = Group('Stuff', parent=status_icon) + + cmd2 = toga.Command( + callback, + text='Stuff sub-command', + group=stuff_group + ) + + # Add the status icon to the app + app.status_icons.add(status_icon_1, status_icon_2) + + # Add the commands to the status icons command set + app.status_icons.commands.add(cmd1, cmd2) + +If you add at least one :class:`~toga.MenuStatusIcon` instance to your app, Toga will +add some standard commands to the app's status icon command set. These items will appear +at the end of the first :class:`~toga.MenuStatusIcon`'s menu. To remove these +items, clear the app's status icon command set before adding your own commands. + +If you add a command to the app's status icon command set that *doesn't* belong to a +:class:`~toga.MenuStatusIcon` group, or that belongs to a :class:`~toga.MenuStatusIcon` +group that hasn't been registered with the app as a status icon, a ``ValueError`` will +be raised. An error will also be raised if you *remove* a status icon while there status +icon commands referencing that command. + +Notes +----- + +* Status icons on GTK are implemented using the `XApp + `__ library. This requires that the user has + installed the system packages for ``libxapp``, plus the GObject Introspection bindings + for that library. The name of the system package required is distribution dependent: + + - Ubuntu: ``gir1.2-xapp-1.0`` + - Fedora: ``xapps`` + +* The GNOME desktop environment does not provide built-in support for status icons. + `This is an explicit design decision on their part + `__, and they advise + against using status icons as part of your app design. However, there are GNOME shell + extensions that can add support for status icons. Other GTK-based desktop environments + (such as Cinnamon) *do* support status icons. + +Reference +--------- + +.. autoclass:: toga.statusicons.StatusIcon + +.. autoclass:: toga.SimpleStatusIcon + +.. autoclass:: toga.MenuStatusIcon + +.. autoclass:: toga.statusicons.StatusIconSet diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 2c533961ba..ccebcf7aff 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -41,6 +41,7 @@ Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily Image,Resource,:class:`~toga.Image`,Graphical content of arbitrary size.,|y|,|y|,|y|,|y|,|y|,, ListSource,Resource,:class:`~toga.sources.ListSource`,A data source describing an ordered list of data.,|y|,|y|,|y|,|y|,|y|,|y|,|y| Source,Resource,:class:`~toga.sources.Source`,A base class for data source implementations.,|y|,|y|,|y|,|y|,|y|,|y|,|y| +StatusIcons,Resource,:ref:`Status Icons `,Icons that appear in the system tray for representing app status while the app isn't visible.,|y|,|y|,|y|,,,, TreeSource,Resource,:class:`~toga.sources.TreeSource`,A data source describing an ordered hierarchical tree of data.,|y|,|y|,|y|,|y|,|y|,|y|,|y| Validators,Resource,:ref:`Validators `,A mechanism for validating that input meets a given set of criteria.,|y|,|y|,|y|,|y|,|y|,|y|,|y| ValueSource,Resource,:class:`~toga.sources.ValueSource`,A data source describing a single value.,|y|,|y|,|y|,|y|,|y|,|y|,|y| diff --git a/docs/reference/images/statusicons-cocoa.png b/docs/reference/images/statusicons-cocoa.png new file mode 100644 index 0000000000..fb0ad78b2f Binary files /dev/null and b/docs/reference/images/statusicons-cocoa.png differ diff --git a/docs/reference/images/statusicons-gtk.png b/docs/reference/images/statusicons-gtk.png new file mode 100644 index 0000000000..707f4c0182 Binary files /dev/null and b/docs/reference/images/statusicons-gtk.png differ diff --git a/docs/reference/images/statusicons-winforms.png b/docs/reference/images/statusicons-winforms.png new file mode 100644 index 0000000000..8c81d7ed3e Binary files /dev/null and b/docs/reference/images/statusicons-winforms.png differ diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index f9e52ddf17..78e58abad2 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -86,6 +86,7 @@ toolbar toolbars Toolbars toolkits +tooltip Towncrier triaged Triaging diff --git a/dummy/src/toga_dummy/factory.py b/dummy/src/toga_dummy/factory.py index 34863850b3..3747eeacda 100644 --- a/dummy/src/toga_dummy/factory.py +++ b/dummy/src/toga_dummy/factory.py @@ -9,6 +9,7 @@ from .icons import Icon from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet from .widgets.activityindicator import ActivityIndicator from .widgets.base import Widget from .widgets.box import Box @@ -55,6 +56,10 @@ def not_implemented(feature): # Hardware "Camera", "Location", + # Status Icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # Widgets "ActivityIndicator", "Box", diff --git a/dummy/src/toga_dummy/screens.py b/dummy/src/toga_dummy/screens.py index 07ff82afb6..61f108e2d8 100644 --- a/dummy/src/toga_dummy/screens.py +++ b/dummy/src/toga_dummy/screens.py @@ -3,7 +3,7 @@ from toga.screens import Screen as ScreenInterface from toga.types import Position, Size -from .utils import LoggedObject # noqa +from .utils import LoggedObject class Screen(LoggedObject): diff --git a/dummy/src/toga_dummy/statusicons.py b/dummy/src/toga_dummy/statusicons.py new file mode 100644 index 0000000000..ba5b2b2e0d --- /dev/null +++ b/dummy/src/toga_dummy/statusicons.py @@ -0,0 +1,33 @@ +from .utils import LoggedObject + + +class StatusIcon(LoggedObject): + def __init__(self, interface): + self.interface = interface + + def set_icon(self, icon): + self._action("set icon", icon=icon) + + def create(self): + self._action("create") + self.set_icon(self.interface.icon) + + def remove(self): + self._action("remove") + + +class SimpleStatusIcon(StatusIcon): + def simulate_press(self): + self.interface.on_press() + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet(LoggedObject): + def __init__(self, interface): + self.interface = interface + + def create(self): + self._action("create status icons") diff --git a/examples/statusiconapp/statusiconapp/app.py b/examples/statusiconapp/statusiconapp/app.py index 398d26a3b6..3151e55b9b 100644 --- a/examples/statusiconapp/statusiconapp/app.py +++ b/examples/statusiconapp/statusiconapp/app.py @@ -8,10 +8,65 @@ def startup(self): # Set app to be a background app self.main_window = toga.App.BACKGROUND - # This app has no user interface at present. It exists to demonstrate how you - # can build an app that persists in the background with no main window. - # - # Support for defining status icons is coming soon (See #97) + # Declare 3 status icons. The first two will have menus, but only the first will + # have the standard commands. The third status icon is a bare icon. + status_1 = toga.MenuStatusIcon(icon="resources/status-icon.png") + self.status_icons.add( + status_1, + toga.MenuStatusIcon(id="second", text="Other icon"), + toga.SimpleStatusIcon(icon="resources/blue.png", on_press=self.do_stuff), + ) + + # Create some commands that can be added to the first status icon. + # A MenuStatusIcon is also a group, so the commands will appear in that group. + cmd1 = toga.Command( + self.do_stuff, + text="Action 1", + tooltip="Perform action 1", + order=2, + group=status_1, + ) + # If a command *doesn't* define a group, and it's added as a status icon, it + # will be added to the end of the first status icon that has been registered. + cmd2 = toga.Command( + self.do_stuff, + text="Action 2", + tooltip="Perform action 2", + order=1, + ) + + # Create a submenu on the first status menu; the status icon is the parent group. + sub_menu = toga.Group("Sub Menu", parent=status_1, order=3) + cmd3 = toga.Command( + self.do_stuff, + text="Action 3", + tooltip="Perform action 3", + group=sub_menu, + ) + cmd4 = toga.Command( + self.do_stuff, + text="Action 4", + tooltip="Perform action 4", + group=sub_menu, + ) + + # Two commands for the second status icon. The status icon can be retrieved by ID, + # and by index. + cmd5 = toga.Command( + self.do_stuff, + text="Action 5", + tooltip="Perform action 5", + group=self.status_icons["second"], + ) + cmd6 = toga.Command( + self.do_stuff, + text="Action 6", + tooltip="Perform action 6", + group=self.status_icons[1], + ) + + # Add the commands that will be part of status icons. + self.status_icons.commands.add(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6) async def on_running(self): # Once the app is running, start a heartbeat @@ -19,6 +74,11 @@ async def on_running(self): await asyncio.sleep(1) print("Running...") + def do_stuff(self, widget, **kwargs): + window = toga.Window(title="Stuff to do") + window.content = toga.Box() + window.show() + def main(): return ExampleStatusIconApp( diff --git a/examples/statusiconapp/statusiconapp/resources/blue.png b/examples/statusiconapp/statusiconapp/resources/blue.png new file mode 100644 index 0000000000..782211bfa9 Binary files /dev/null and b/examples/statusiconapp/statusiconapp/resources/blue.png differ diff --git a/examples/statusiconapp/statusiconapp/resources/status-icon.png b/examples/statusiconapp/statusiconapp/resources/status-icon.png new file mode 100644 index 0000000000..c275f45edc Binary files /dev/null and b/examples/statusiconapp/statusiconapp/resources/status-icon.png differ diff --git a/gtk/src/toga_gtk/command.py b/gtk/src/toga_gtk/command.py index 862842b355..d9eb7c1317 100644 --- a/gtk/src/toga_gtk/command.py +++ b/gtk/src/toga_gtk/command.py @@ -91,7 +91,7 @@ def standard(self, app, id): raise ValueError(f"Unknown standard command {id!r}") - def gtk_activate(self, action, data): + def gtk_activate(self, action, data=None): self.interface.action() def gtk_clicked(self, action): diff --git a/gtk/src/toga_gtk/factory.py b/gtk/src/toga_gtk/factory.py index 3aa439b6a6..ccd86eb40c 100644 --- a/gtk/src/toga_gtk/factory.py +++ b/gtk/src/toga_gtk/factory.py @@ -7,6 +7,7 @@ from .icons import Icon from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button @@ -47,6 +48,10 @@ def not_implemented(feature): "Image", "Paths", "dialogs", + # Status icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # Widgets "ActivityIndicator", "Box", diff --git a/gtk/src/toga_gtk/libs/gtk.py b/gtk/src/toga_gtk/libs/gtk.py index 6e302197d0..f6f4f8cc01 100644 --- a/gtk/src/toga_gtk/libs/gtk.py +++ b/gtk/src/toga_gtk/libs/gtk.py @@ -56,3 +56,9 @@ from gi.repository import PangoFc # noqa: F401 except (ImportError, ValueError): # pragma: no cover PangoFc = None + +try: + gi.require_version("XApp", "1.0") + from gi.repository import XApp # noqa: F401 +except (ImportError, ValueError): # pragma: no cover + XApp = None diff --git a/gtk/src/toga_gtk/statusicons.py b/gtk/src/toga_gtk/statusicons.py new file mode 100644 index 0000000000..7ecda53f9b --- /dev/null +++ b/gtk/src/toga_gtk/statusicons.py @@ -0,0 +1,112 @@ +import toga +from toga.command import Group, Separator + +from .libs import Gtk, XApp + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + if self.native: + path = str( + icon._impl.paths[32] if icon else toga.App.app.icon._impl.paths[32] + ) + self.native.set_icon_name(path) + + def create(self): + if XApp is None: # pragma: no cover + # Can't replicate this in testbed + raise RuntimeError( + "Unable to import XApp. Ensure that the system package " + "providing libxapp and its GTK bindings have been installed." + ) + + self.native = XApp.StatusIcon.new() + self.native.set_tooltip_text(self.interface.text) + self.set_icon(self.interface.icon) + + def remove(self): + del self.native + self.native = None + + +class SimpleStatusIcon(StatusIcon): + def create(self): + super().create() + self.native.connect("activate", self.gtk_activate) + + def gtk_activate(self, icon, button, time): + self.interface.on_press() + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + self._menu_items = {} + + def _submenu(self, group, group_cache): + try: + return group_cache[group] + except KeyError: + if group is None: + raise ValueError("Unknown top level item") + else: + parent_menu = self._submenu(group.parent, group_cache) + + submenu = Gtk.Menu.new() + item = Gtk.MenuItem.new_with_label(group.text) + item.set_submenu(submenu) + + parent_menu.append(item) + parent_menu.show_all() + + group_cache[group] = submenu + return submenu + + def create(self): + # Menu status icons are the only icons that have extra construction needs. + # Clear existing menus + for item in self.interface._menu_status_icons: + submenu = Gtk.Menu.new() + item._impl.native.set_primary_menu(submenu) + + # Determine the primary status icon. + primary_group = self.interface._primary_menu_status_icon + if primary_group is None: # pragma: no cover + # If there isn't at least one menu status icon, then there aren't any menus + # to populate. This can't be replicated in the testbed. + return + + # Add the menu status items to the cache + group_cache = { + item: item._impl.native.get_primary_menu() + for item in self.interface._menu_status_icons + } + # Map the COMMANDS group to the primary status icon's menu. + group_cache[Group.COMMANDS] = primary_group._impl.native.get_primary_menu() + self._menu_items = {} + + for cmd in self.interface.commands: + try: + submenu = self._submenu(cmd.group, group_cache) + except ValueError: + raise ValueError( + f"Command {cmd.text!r} does not belong to " + "a current status icon group." + ) + else: + if isinstance(cmd, Separator): + menu_item = Gtk.SeparatorMenuItem.new() + else: + menu_item = Gtk.MenuItem.new_with_label(cmd.text) + menu_item.connect("activate", cmd._impl.gtk_activate) + + submenu.append(menu_item) + submenu.show_all() diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 4c4b22247c..e00d9b205c 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -228,3 +228,30 @@ async def open_initial_document(self, monkeypatch, document_path): def open_document_by_drag(self, document_path): pytest.xfail("GTK doesn't support opening documents by drag") + + def has_status_icon(self, status_icon): + return status_icon._impl.native is not None + + def status_menu_items(self, status_icon): + menu = status_icon._impl.native.get_primary_menu() + if menu: + return [ + { + "": "---", + "About Toga Testbed": "**ABOUT**", + "Quit": "**EXIT**", + }.get(child.get_label(), child.get_label()) + for child in menu.get_children() + ] + else: + # It's a button status item + return None + + def activate_status_icon_button(self, item_id): + self.app.status_icons[item_id]._impl.native.emit("activate", 0, 0) + + def activate_status_menu_item(self, item_id, title): + menu = self.app.status_icons[item_id]._impl.native.get_primary_menu() + item = {child.get_label(): child for child in menu.get_children()}[title] + + item.emit("activate") diff --git a/iOS/src/toga_iOS/factory.py b/iOS/src/toga_iOS/factory.py index 53477d13b6..a8ac897ccb 100644 --- a/iOS/src/toga_iOS/factory.py +++ b/iOS/src/toga_iOS/factory.py @@ -10,6 +10,7 @@ from .icons import Icon from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet # Widgets from .widgets.box import Box @@ -58,6 +59,10 @@ def not_implemented(feature): # Hardware "Camera", "Location", + # Status icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # Widgets "Box", "Button", diff --git a/iOS/src/toga_iOS/statusicons.py b/iOS/src/toga_iOS/statusicons.py new file mode 100644 index 0000000000..22e869912b --- /dev/null +++ b/iOS/src/toga_iOS/statusicons.py @@ -0,0 +1,32 @@ +import toga + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + pass + + def create(self): + toga.NotImplementedWarning.warn("iOS", "Status Icons") + + def remove(self): + pass # pragma: no cover + + +class SimpleStatusIcon(StatusIcon): + pass + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + + def create(self): + pass diff --git a/iOS/tests_backend/app.py b/iOS/tests_backend/app.py index 092c907da1..d0810d7224 100644 --- a/iOS/tests_backend/app.py +++ b/iOS/tests_backend/app.py @@ -76,3 +76,15 @@ def terminate(self): def rotate(self): self.native = self.app._impl.native self.native.delegate.application(self.native, didChangeStatusBarOrientation=0) + + def has_status_icon(self, status_icon): + pytest.xfail("Status icons not implemented on iOS") + + def status_menu_items(self, status_icon): + pytest.xfail("Status icons not implemented on iOS") + + def activate_status_icon_button(self, item_id): + pytest.xfail("Status icons not implemented on iOS") + + def activate_status_menu_item(self, item_id, title): + pytest.xfail("Status icons not implemented on iOS") diff --git a/testbed/src/testbed/app.py b/testbed/src/testbed/app.py index 1774a5bdd0..015727316d 100644 --- a/testbed/src/testbed/app.py +++ b/testbed/src/testbed/app.py @@ -131,6 +131,74 @@ def startup(self): toga.Command.standard(self, toga.Command.PREFERENCES), ) + # Set up some status icons. This will raise warnings on mobile. + self.status1 = toga.MenuStatusIcon(icon="resources/icons/red.png") + self.status2 = toga.MenuStatusIcon(id="second", text="Other icon") + self.status_button = toga.SimpleStatusIcon( + id="button", + icon="resources/icons/blue.png", + on_press=self.cmd_action, + ) + self.status_icons.add( + self.status1, + self.status2, + self.status_button, + ) + + # Commands for the first status icon + self.status_cmd1 = toga.Command( + self.cmd_action, + text="Action 1", + tooltip="Perform action 1", + group=self.status1, + ) + # If a command *doesn't* define a group, and it's added as a status icon, it + # will be added to the end of the first status icon that has been registered. + self.status_cmd2 = toga.Command( + self.cmd_action, + text="Action 2", + tooltip="Perform action 2", + ) + + # Create a submenu on the first status menu; the status icon is the parent group. + self.status1_sub_menu = toga.Group("Sub Menu", parent=self.status1, order=3) + self.status_cmd3 = toga.Command( + self.cmd_action, + text="Action 3", + tooltip="Perform action 3", + group=self.status1_sub_menu, + ) + self.status_cmd4 = toga.Command( + self.cmd_action, + text="Action 4", + tooltip="Perform action 4", + group=self.status1_sub_menu, + ) + + # Commands for the second status icon + self.status_cmd5 = toga.Command( + self.cmd_action, + text="Action 5", + tooltip="Perform action 5", + group=self.status_icons["second"], + ) + self.status_cmd6 = toga.Command( + self.cmd_action, + text="Action 6", + tooltip="Perform action 6", + group=self.status_icons[1], + ) + + # Add the commands that will be part of status icons. + self.status_icons.commands.add( + self.status_cmd1, + self.status_cmd2, + self.status_cmd3, + self.status_cmd4, + self.status_cmd5, + self.status_cmd6, + ) + self.main_window = toga.MainWindow(title=self.formal_name) self.main_window.content = toga.Box( children=[ diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index aa021ae0ba..834a31424e 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -51,6 +51,9 @@ async def app_probe(app): # minimize garbage collection on the test thread. gc.collect() + # Reset the command action mock + app.cmd_action.reset_mock() + @fixture(scope="session") def main_window(app): diff --git a/testbed/tests/test_statusicons.py b/testbed/tests/test_statusicons.py new file mode 100644 index 0000000000..6241e60d90 --- /dev/null +++ b/testbed/tests/test_statusicons.py @@ -0,0 +1,133 @@ +from unittest.mock import Mock + +import pytest + +import toga + + +async def test_add_remove(app, app_probe): + """Status icons and items can be added and removed.""" + # Assert the structure of the default status items + assert app_probe.has_status_icon(app.status1) + assert app_probe.status_menu_items(app.status1) == [ + "Action 1", + "Sub Menu", + "Action 2", + # The standard commands come after a separator; + # but the text varies depending on the platform. + "---", + "**ABOUT**", + "**EXIT**", + ] + + assert app_probe.has_status_icon(app.status2) + assert app_probe.status_menu_items(app.status2) == [ + "Action 5", + "Action 6", + ] + + assert app_probe.has_status_icon(app.status_button) + assert app_probe.status_menu_items(app.status_button) is None + + # Create a new menu status item + new_status_icon = toga.MenuStatusIcon(text="New Item") + new_cmd1 = toga.Command( + Mock(), + text="New Action 1", + tooltip="Perform action 1", + group=new_status_icon, + order=20, + ) + await app_probe.redraw("Status icon created but not added") + assert not app_probe.has_status_icon(new_status_icon) + + # Add the command to the status command set. It won't have any commands yet. + app.status_icons.add(new_status_icon) + await app_probe.redraw("Status icon added") + assert app_probe.has_status_icon(new_status_icon) + assert app_probe.status_menu_items(new_status_icon) == [] + + # Add the command to the status command set. + app.status_icons.commands.add(new_cmd1) + await app_probe.redraw("Command added, but not visible") + assert app_probe.has_status_icon(new_status_icon) + assert app_probe.status_menu_items(new_status_icon) == [ + "New Action 1", + ] + + # A second command + new_cmd2 = toga.Command( + Mock(), + text="New Action 2", + tooltip="Perform new action 2", + group=new_status_icon, + order=10, + ) + app.status_icons.commands.add(new_cmd2) + await app_probe.redraw("Second command added") + assert app_probe.has_status_icon(new_status_icon) + assert app_probe.status_menu_items(new_status_icon) == [ + "New Action 2", + "New Action 1", + ] + + # Remove the first command + app.status_icons.commands.remove(new_cmd1) + await app_probe.redraw("First command removed") + assert app_probe.has_status_icon(new_status_icon) + assert app_probe.status_menu_items(new_status_icon) == [ + "New Action 2", + ] + + # Remove the second command + app.status_icons.commands.remove(new_cmd2) + await app_probe.redraw("Second command removed") + assert app_probe.has_status_icon(new_status_icon) + assert app_probe.status_menu_items(new_status_icon) == [] + + # Remove the extra status icon + app.status_icons.remove(new_status_icon) + await app_probe.redraw("Status icon removed") + assert not app_probe.has_status_icon(new_status_icon) + + +async def test_unknown_status_icon(app, app_probe): + """Adding a command when the status icon is unknown raises an error.""" + # Verify the app status icons are present. This enforces the test skip + # if the platform doesn't support status icons. + assert app_probe.has_status_icon(app.status1) + + # Create a new menu status item + absent_status_icon = toga.MenuStatusIcon(text="Absent Item") + bad_cmd = toga.Command( + Mock(), + text="Bad Action", + group=absent_status_icon, + ) + + # Add the command without adding the status icon first + try: + with pytest.raises( + ValueError, + match=r"Command 'Bad Action' does not belong to a current status icon group.", + ): + app.status_icons.commands.add(bad_cmd) + finally: + # Clean up and make sure the bad command is removed. + app.status_icons.commands.remove(bad_cmd) + + +async def test_activate_button_icon(app, app_probe): + """A button status icon can be activated.""" + app_probe.activate_status_icon_button("button") + await app_probe.redraw("Pressed status icon button") + + app.cmd_action.assert_called_once_with(app.status_icons["button"]) + + +async def test_activate_status_menu_item(app, app_probe): + """A menu status item can be activated.""" + app_probe.activate_status_menu_item("second", "Action 5") + await app_probe.redraw("Pressed menu status item") + + app.cmd_action.assert_called_once_with(app.status_cmd5) diff --git a/textual/src/toga_textual/factory.py b/textual/src/toga_textual/factory.py index 8113fbda21..b737aed521 100644 --- a/textual/src/toga_textual/factory.py +++ b/textual/src/toga_textual/factory.py @@ -9,6 +9,7 @@ # from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet # from .widgets.activityindicator import ActivityIndicator # from .widgets.base import Widget @@ -54,6 +55,10 @@ def not_implemented(feature): # "Image", "Paths", "dialogs", + # Status Icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # # Widgets # "ActivityIndicator", "Box", diff --git a/textual/src/toga_textual/statusicons.py b/textual/src/toga_textual/statusicons.py new file mode 100644 index 0000000000..1c4f18b268 --- /dev/null +++ b/textual/src/toga_textual/statusicons.py @@ -0,0 +1,32 @@ +import toga + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + pass + + def create(self): + toga.NotImplementedWarning.warn("Textual", "Status Icons") + + def remove(self): + pass + + +class SimpleStatusIcon(StatusIcon): + pass + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + + def create(self): + pass diff --git a/web/src/toga_web/factory.py b/web/src/toga_web/factory.py index 5ba6c83056..ec246f3995 100644 --- a/web/src/toga_web/factory.py +++ b/web/src/toga_web/factory.py @@ -9,6 +9,7 @@ # from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet from .widgets.activityindicator import ActivityIndicator from .widgets.box import Box from .widgets.button import Button @@ -53,6 +54,10 @@ def not_implemented(feature): # 'Image', "Paths", "dialogs", + # Status Icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # # Widgets "Box", "Button", diff --git a/web/src/toga_web/statusicons.py b/web/src/toga_web/statusicons.py new file mode 100644 index 0000000000..861b15b661 --- /dev/null +++ b/web/src/toga_web/statusicons.py @@ -0,0 +1,32 @@ +import toga + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + pass + + def create(self): + toga.NotImplementedWarning.warn("Web", "Status Icons") + + def remove(self): + pass + + +class SimpleStatusIcon(StatusIcon): + pass + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + + def create(self): + pass diff --git a/winforms/src/toga_winforms/command.py b/winforms/src/toga_winforms/command.py index 489cbd5657..8b888bd521 100644 --- a/winforms/src/toga_winforms/command.py +++ b/winforms/src/toga_winforms/command.py @@ -1,6 +1,10 @@ import sys +from System.ComponentModel import InvalidEnumArgumentException + from toga import Command as StandardCommand, Group, Key +from toga_winforms.keys import toga_to_winforms_key, toga_to_winforms_shortcut +from toga_winforms.libs.wrapper import WeakrefCallable class Command: @@ -88,3 +92,30 @@ def set_enabled(self, value): if self.native: for widget in self.native: widget.Enabled = self.interface.enabled + + def create_menu_item(self, WinformsClass): + item = WinformsClass(self.interface.text) + + item.Click += WeakrefCallable(self.winforms_Click) + if self.interface.shortcut is not None: + try: + item.ShortcutKeys = toga_to_winforms_key(self.interface.shortcut) + # The Winforms key enum is... daft. The "oem" key + # values render as "Oem" or "Oemcomma", so we need to + # *manually* set the display text for the key shortcut. + item.ShortcutKeyDisplayString = toga_to_winforms_shortcut( + self.interface.shortcut + ) + except ( + ValueError, + InvalidEnumArgumentException, + ) as e: # pragma: no cover + # Make this a non-fatal warning, because different backends may + # accept different shortcuts. + print(f"WARNING: invalid shortcut {self.interface.shortcut!r}: {e}") + + item.Enabled = self.interface.enabled + + self.native.append(item) + + return item diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index 5c7e035366..de24bc1f62 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -7,6 +7,7 @@ from .icons import Icon from .images import Image from .paths import Paths +from .statusicons import MenuStatusIcon, SimpleStatusIcon, StatusIconSet from .widgets.box import Box from .widgets.button import Button from .widgets.canvas import Canvas @@ -47,6 +48,10 @@ def not_implemented(feature): "Image", "Paths", "dialogs", + # Status Icons + "MenuStatusIcon", + "SimpleStatusIcon", + "StatusIconSet", # Widgets "Box", "Button", diff --git a/winforms/src/toga_winforms/statusicons.py b/winforms/src/toga_winforms/statusicons.py new file mode 100644 index 0000000000..e581ac69fd --- /dev/null +++ b/winforms/src/toga_winforms/statusicons.py @@ -0,0 +1,103 @@ +import System.Windows.Forms as WinForms + +import toga +from toga.command import Group, Separator + +from .libs.wrapper import WeakrefCallable + + +class StatusIcon: + def __init__(self, interface): + self.interface = interface + self.native = None + + def set_icon(self, icon): + if self.native: + self.native.Icon = ( + icon._impl.native if icon else toga.App.app.icon._impl.native + ) + + def create(self): + self.native = WinForms.NotifyIcon() + self.native.Visible = True + self.native.Text = self.interface.text + self.set_icon(self.interface.icon) + + def remove(self): + self.native.Visible = False + self.native.Dispose() + self.native = None + + +class SimpleStatusIcon(StatusIcon): + def create(self): + super().create() + self.native.Click += WeakrefCallable(self.winforms_click) + + def winforms_click(self, sender, event): + self.interface.on_press() + + +class MenuStatusIcon(StatusIcon): + pass + + +class StatusIconSet: + def __init__(self, interface): + self.interface = interface + self._menu_items = {} + + def _submenu(self, group, group_cache): + try: + return group_cache[group] + except KeyError: + if group is None: + raise ValueError("Unknown top level item") + else: + parent_menu = self._submenu(group.parent, group_cache) + + submenu = WinForms.MenuItem(group.text) + + parent_menu.MenuItems.Add(submenu) + + group_cache[group] = submenu + return submenu + + def create(self): + # Menu status icons are the only icons that have extra construction needs. + # Clear existing menus + for item in self.interface._menu_status_icons: + submenu = WinForms.ContextMenu() + item._impl.native.ContextMenu = submenu + + # Determine the primary status icon. + primary_group = self.interface._primary_menu_status_icon + if primary_group is None: # pragma: no cover + # If there isn't at least one menu status icon, then there aren't any menus + # to populate. This can't be replicated in the testbed. + return + + # Add the menu status items to the cache + group_cache = { + item: item._impl.native.ContextMenu + for item in self.interface._menu_status_icons + } + # Map the COMMANDS group to the primary status icon's menu. + group_cache[Group.COMMANDS] = primary_group._impl.native.ContextMenu + self._menu_items = {} + + for cmd in self.interface.commands: + try: + submenu = self._submenu(cmd.group, group_cache) + except ValueError: + raise ValueError( + f"Command {cmd.text!r} does not belong to " + "a current status icon group." + ) + else: + if isinstance(cmd, Separator): + menu_item = "-" + else: + menu_item = cmd._impl.create_menu_item(WinForms.MenuItem) + + submenu.MenuItems.Add(menu_item) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 31d3c80907..7ff283f27e 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING import System.Windows.Forms as WinForms -from System.ComponentModel import InvalidEnumArgumentException from System.Drawing import Bitmap, Graphics, Point, Size as WinSize from System.Drawing.Imaging import ImageFormat from System.IO import MemoryStream @@ -12,7 +11,6 @@ from toga.types import Position, Size from .container import Container -from .keys import toga_to_winforms_key, toga_to_winforms_shortcut from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl from .widgets.base import Scalable @@ -225,24 +223,21 @@ def _top_bars_height(self): vertical_shift += self.native.MainMenuStrip.Height return vertical_shift - def _submenu(self, group, menubar): + def _submenu(self, group, group_cache): try: - return self._menu_groups[group] + return group_cache[group] except KeyError: - if group is None: - submenu = menubar - else: - parent_menu = self._submenu(group.parent, menubar) + parent_menu = self._submenu(group.parent, group_cache) - submenu = WinForms.ToolStripMenuItem(group.text) + submenu = WinForms.ToolStripMenuItem(group.text) - # Top level menus are added in a different way to submenus - if group.parent is None: - parent_menu.Items.Add(submenu) - else: - parent_menu.DropDownItems.Add(submenu) + # Top level menus are added in a different way to submenus + if group.parent is None: + parent_menu.Items.Add(submenu) + else: + parent_menu.DropDownItems.Add(submenu) - self._menu_groups[group] = submenu + group_cache[group] = submenu return submenu def create_menus(self): @@ -257,38 +252,17 @@ def create_menus(self): self.native.MainMenuStrip = menubar menubar.SendToBack() # In a dock, "back" means "top". - self._menu_groups = {} + group_cache = {None: menubar} submenu = None for cmd in self.interface.app.commands: - submenu = self._submenu(cmd.group, menubar) + submenu = self._submenu(cmd.group, group_cache) if isinstance(cmd, Separator): - submenu.DropDownItems.Add("-") + item = "-" else: - submenu = self._submenu(cmd.group, menubar) - item = WinForms.ToolStripMenuItem(cmd.text) - item.Click += WeakrefCallable(cmd._impl.winforms_Click) - if cmd.shortcut is not None: - try: - item.ShortcutKeys = toga_to_winforms_key(cmd.shortcut) - # The Winforms key enum is... daft. The "oem" key - # values render as "Oem" or "Oemcomma", so we need to - # *manually* set the display text for the key shortcut. - item.ShortcutKeyDisplayString = toga_to_winforms_shortcut( - cmd.shortcut - ) - except ( - ValueError, - InvalidEnumArgumentException, - ) as e: # pragma: no cover - # Make this a non-fatal warning, because different backends may - # accept different shortcuts. - print(f"WARNING: invalid shortcut {cmd.shortcut!r}: {e}") - - item.Enabled = cmd.enabled - - cmd._impl.native.append(item) - submenu.DropDownItems.Add(item) + item = cmd._impl.create_menu_item(WinForms.ToolStripMenuItem) + + submenu.DropDownItems.Add(item) self.resize_content() diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 323d7d630f..8985cffc57 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -218,3 +218,33 @@ async def open_initial_document(self, monkeypatch, document_path): def open_document_by_drag(self, document_path): pytest.xfail("Winforms doesn't support opening documents by drag") + + def has_status_icon(self, status_icon): + return status_icon._impl.native is not None + + def status_menu_items(self, status_icon): + if status_icon._impl.native.ContextMenu: + return [ + { + "-": "---", + "About Toga Testbed": "**ABOUT**", + "Exit": "**EXIT**", + }.get(str(item.Text), str(item.Text)) + for item in status_icon._impl.native.ContextMenu.MenuItems + ] + else: + # It's a button status item + return None + + def activate_status_icon_button(self, item_id): + # Winforms doesn't provide an OnClick to trigger clicks, so we have to fake it + # at the level of the impl. + self.app.status_icons[item_id]._impl.winforms_click( + self.app.status_icons[item_id]._impl.native, + EventArgs.Empty, + ) + + def activate_status_menu_item(self, item_id, title): + menu = self.app.status_icons[item_id]._impl.native.ContextMenu + item = {item.Text: item for item in menu.MenuItems}[title] + item.OnClick(EventArgs.Empty)