diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index b3633fa054..289f976547 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -11,6 +11,7 @@ from org.beeware.android import IPythonApp, MainActivity from toga.command import Command, Group, Separator +from toga.handlers import simple_handler from .libs import events from .screens import Screen as ScreenImpl @@ -207,9 +208,10 @@ def create_app_commands(self): self.interface.commands.add( # About should be the last item in the menu, in a section on its own. Command( - lambda _: self.interface.about(), + simple_handler(self.interface.about), f"About {self.interface.formal_name}", section=sys.maxsize, + id=Command.ABOUT, ), ) diff --git a/changes/2636.feature.rst b/changes/2636.feature.rst new file mode 100644 index 0000000000..94bff35fe3 --- /dev/null +++ b/changes/2636.feature.rst @@ -0,0 +1 @@ +Commands can now be retrieved by ID. System-installed commands (such as "About" and "Visit Homepage") are installed using a known ID that can be used at runtime to manipulate those commands. diff --git a/changes/90.feature.rst b/changes/90.feature.rst new file mode 100644 index 0000000000..9cd58c333a --- /dev/null +++ b/changes/90.feature.rst @@ -0,0 +1 @@ +An app can now define a ``preferences()`` method to hook into the default menu item generated by Toga. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index d367b9c68b..f7624ae1fa 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -8,8 +8,9 @@ from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy import toga -from toga.command import Separator -from toga.handlers import NativeHandler +from toga.app import overridden +from toga.command import Command, Separator +from toga.handlers import NativeHandler, simple_handler from .keys import cocoa_key from .libs import ( @@ -162,9 +163,6 @@ def create(self): # Commands and menus ###################################################################### - def _menu_about(self, command, **kwargs): - self.interface.about() - def _menu_close_all_windows(self, command, **kwargs): # Convert to a list to so that we're not altering a set while iterating for window in list(self.interface.windows): @@ -178,29 +176,28 @@ def _menu_minimize(self, command, **kwargs): if self.interface.current_window: self.interface.current_window._impl.native.miniaturize(None) - def _menu_quit(self, command, **kwargs): - self.interface.on_exit() - - def _menu_visit_homepage(self, command, **kwargs): - self.interface.visit_homepage() - def create_app_commands(self): formal_name = self.interface.formal_name self.interface.commands.add( # ---- App menu ----------------------------------- - toga.Command( - self._menu_about, + Command( + simple_handler(self.interface.about), "About " + formal_name, group=toga.Group.APP, + id=Command.ABOUT, ), - toga.Command( - None, + # Include a preferences menu item; but only enable it if the user has + # overridden it in their App class. + Command( + simple_handler(self.interface.preferences), "Settings\u2026", shortcut=toga.Key.MOD_1 + ",", group=toga.Group.APP, section=20, + enabled=overridden(self.interface.preferences), + id=Command.PREFERENCES, ), - toga.Command( + Command( NativeHandler(SEL("hide:")), "Hide " + formal_name, shortcut=toga.Key.MOD_1 + "h", @@ -208,7 +205,7 @@ def create_app_commands(self): order=0, section=sys.maxsize - 1, ), - toga.Command( + Command( NativeHandler(SEL("hideOtherApplications:")), "Hide Others", shortcut=toga.Key.MOD_1 + toga.Key.MOD_2 + "h", @@ -216,20 +213,23 @@ def create_app_commands(self): order=1, section=sys.maxsize - 1, ), - toga.Command( + Command( NativeHandler(SEL("unhideAllApplications:")), "Show All", group=toga.Group.APP, order=2, section=sys.maxsize - 1, ), - # Quit should always be the last item, in a section on its own - toga.Command( - self._menu_quit, - "Quit " + formal_name, + # Quit should always be the last item, in a section on its own. Invoke + # `on_exit` rather than `exit`, because we want to trigger the "OK to exit?" + # logic. It's already a bound handler, so we can use it directly. + Command( + self.interface.on_exit, + f"Quit {formal_name}", shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, section=sys.maxsize, + id=Command.EXIT, ), # ---- File menu ---------------------------------- # This is a bit of an oddity. Apple HIG apps that don't have tabs as @@ -237,7 +237,7 @@ def create_app_commands(self): # have a "Close" item that becomes "Close All" when you press Option # (MOD_2). That behavior isn't something we're currently set up to # implement, so we live with a separate menu item for now. - toga.Command( + Command( self._menu_close_window, "Close", shortcut=toga.Key.MOD_1 + "w", @@ -245,7 +245,7 @@ def create_app_commands(self): order=1, section=50, ), - toga.Command( + Command( self._menu_close_all_windows, "Close All", shortcut=toga.Key.MOD_2 + toga.Key.MOD_1 + "w", @@ -254,21 +254,21 @@ def create_app_commands(self): section=50, ), # ---- Edit menu ---------------------------------- - toga.Command( + Command( NativeHandler(SEL("undo:")), "Undo", shortcut=toga.Key.MOD_1 + "z", group=toga.Group.EDIT, order=10, ), - toga.Command( + Command( NativeHandler(SEL("redo:")), "Redo", shortcut=toga.Key.SHIFT + toga.Key.MOD_1 + "z", group=toga.Group.EDIT, order=20, ), - toga.Command( + Command( NativeHandler(SEL("cut:")), "Cut", shortcut=toga.Key.MOD_1 + "x", @@ -276,7 +276,7 @@ def create_app_commands(self): section=10, order=10, ), - toga.Command( + Command( NativeHandler(SEL("copy:")), "Copy", shortcut=toga.Key.MOD_1 + "c", @@ -284,7 +284,7 @@ def create_app_commands(self): section=10, order=20, ), - toga.Command( + Command( NativeHandler(SEL("paste:")), "Paste", shortcut=toga.Key.MOD_1 + "v", @@ -292,7 +292,7 @@ def create_app_commands(self): section=10, order=30, ), - toga.Command( + Command( NativeHandler(SEL("pasteAsPlainText:")), "Paste and Match Style", shortcut=toga.Key.MOD_2 + toga.Key.SHIFT + toga.Key.MOD_1 + "v", @@ -300,14 +300,14 @@ def create_app_commands(self): section=10, order=40, ), - toga.Command( + Command( NativeHandler(SEL("delete:")), "Delete", group=toga.Group.EDIT, section=10, order=50, ), - toga.Command( + Command( NativeHandler(SEL("selectAll:")), "Select All", shortcut=toga.Key.MOD_1 + "a", @@ -316,18 +316,19 @@ def create_app_commands(self): order=60, ), # ---- Window menu ---------------------------------- - toga.Command( + Command( self._menu_minimize, "Minimize", shortcut=toga.Key.MOD_1 + "m", group=toga.Group.WINDOW, ), # ---- Help menu ---------------------------------- - toga.Command( - self._menu_visit_homepage, + Command( + simple_handler(self.interface.visit_homepage), "Visit homepage", enabled=self.interface.home_page is not None, group=toga.Group.HELP, + id=Command.VISIT_HOMEPAGE, ), ) diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 65ab6fb08d..d4c4447947 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -300,6 +300,21 @@ def _default_title(self) -> str: return self.doc.path.name +def overridable(method): + """Decorate the method as being user-overridable""" + method._overridden = True + return method + + +def overridden(coroutine_or_method): + """Has the user overridden this method? + + This is based on the method *not* having a ``_overridden`` attribute. Overridable + default methods have this attribute; user-defined method will not. + """ + return not hasattr(coroutine_or_method, "_overridden") + + class App: #: The currently running :class:`~toga.App`. Since there can only be one running #: Toga app in a process, this is available as a class property via ``toga.App.app``. @@ -746,10 +761,25 @@ def beep(self) -> None: """Play the default system notification sound.""" self._impl.beep() + @overridable + def preferences(self) -> None: + """Open a preferences panel for the app. + + By default, this will do nothing, and the Preferences/Settings menu item + will be disabled. However, if you override this method in your App class, + the menu item will be enabled, and this method will be invoked when the + menu item is selected. + """ + # Default implementation won't ever be invoked, because the menu item + # isn't enabled unless it's overridden. + pass # pragma: no cover + def visit_homepage(self) -> None: """Open the application's :any:`home_page` in the default browser. - If the :any:`home_page` is ``None``, this is a no-op. + This method is invoked as a handler by the "Visit homepage" default menu item. + If the :any:`home_page` is ``None``, this is a no-op, and the default menu item + will be disabled. """ if self.home_page is not None: webbrowser.open(self.home_page) diff --git a/core/src/toga/command.py b/core/src/toga/command.py index ba6bb5f956..cdea78de31 100644 --- a/core/src/toga/command.py +++ b/core/src/toga/command.py @@ -140,10 +140,10 @@ def key(self) -> tuple[tuple[int, int, str], ...]: HELP: Group #: Help commands -Group.APP = Group("*", order=0) -Group.FILE = Group("File", order=1) -Group.EDIT = Group("Edit", order=10) -Group.VIEW = Group("View", order=20) +Group.APP = Group("*", order=-100) +Group.FILE = Group("File", order=-30) +Group.EDIT = Group("Edit", order=-20) +Group.VIEW = Group("View", order=-10) Group.COMMANDS = Group("Commands", order=30) Group.WINDOW = Group("Window", order=90) Group.HELP = Group("Help", order=100) @@ -160,6 +160,15 @@ def __call__(self, command: Command, **kwargs) -> bool: class Command: + #: An identifier for the system-installed "About" menu item + ABOUT: str = "about" + #: An identifier for the system-installed "Exit" menu item + EXIT: str = "on_exit" + #: An identifier for the system-installed "Preferences" menu item + PREFERENCES: str = "preferences" + #: An identifier for the system-installed "Visit Homepage" menu item + VISIT_HOMEPAGE: str = "visit_homepage" + def __init__( self, action: ActionHandler | None, @@ -172,6 +181,7 @@ def __init__( section: int = 0, order: int = 0, enabled: bool = True, + id: str = None, ): """ Create a new Command. @@ -192,7 +202,9 @@ def __init__( If multiple items have the same group, section and order, they will be sorted alphabetically by their text. :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.text = text self.shortcut = shortcut @@ -211,6 +223,11 @@ def __init__( self._enabled = True self.enabled = enabled + @property + def id(self) -> str: + """A unique identifier for the command.""" + return self._id + @property def key(self) -> tuple[tuple[int, int, str], ...]: """A unique tuple describing the path to this command. @@ -317,38 +334,64 @@ def __init__( CommandSet of your own; you should use existing app or window level CommandSet instances. - The collection can be iterated over to provide the display order of the commands - managed by the group. + The ``in`` operator can be used to evaluate whether a :class:`~toga.Command` is + a member of the CommandSet, using either an instance of a Command, or the ID of + a command. - :param on_change: A method that should be invoked when this command set changes. - :param app: The app this command set is associated with, if it is not the app's - own :any:`CommandSet`. + Commands can be retrieved from the CommandSet using ``[]`` notation with the + requested command's ID. + + When iterated over, a CommandSet returns :class:`~toga.Command` instances in + their sort order, with :class:`~toga.command.Separator` instances inserted + between groups. + + :param on_change: A method that should be invoked when this CommandSet changes. + :param app: The app this CommandSet is associated with, if it is not the app's + own CommandSet. """ self._app = app - self._commands: set[Command | Group] = set() + self._commands: dict[str:Command] = {} self.on_change = on_change - def add(self, *commands: Command | Group) -> None: + def add(self, *commands: Command): + """Add a collection of commands to the command set. + + :param commands: The commands to add to the command set. + """ if self.app: self.app.commands.add(*commands) - self._commands.update(commands) + self._commands.update({cmd.id: cmd for cmd in commands}) if self.on_change: self.on_change() def clear(self) -> None: - self._commands = set() + """Remove all commands from the command set.""" + self._commands = {} if self.on_change: self.on_change() @property def app(self) -> App | None: + """The app this CommandSet is associated with. + + Returns None if this is the app's CommandSet. + """ return self._app + def __contains__(self, obj: str | Command) -> Command: + if isinstance(obj, Command): + return obj in self._commands.values() + else: + return obj in self._commands + + def __getitem__(self, id: str) -> Command: + return self._commands[id] + def __len__(self) -> int: return len(self._commands) def __iter__(self) -> Iterator[Command | Separator]: - cmd_iter = iter(sorted(self._commands)) + cmd_iter = iter(sorted(self._commands.values())) def descendant(group: Group, ancestor: Group) -> Group | None: # Return the immediate descendant of ancestor used by this group. diff --git a/core/src/toga/handlers.py b/core/src/toga/handlers.py index 64a1ea2a74..6119a8f054 100644 --- a/core/src/toga/handlers.py +++ b/core/src/toga/handlers.py @@ -65,6 +65,7 @@ async def long_running_task( except Exception as e: print("Error in long running handler cleanup:", e, file=sys.stderr) traceback.print_exc() + return result async def handler_with_cleanup( @@ -86,6 +87,32 @@ async def handler_with_cleanup( except Exception as e: print("Error in async handler cleanup:", e, file=sys.stderr) traceback.print_exc() + return result + + +def simple_handler(fn): + """Wrap a function (with args and kwargs) so it can be used as a command handler. + + This essentially accepts and ignores the handler-related arguments (i.e., the + required ``command`` argument passed to handlers), so that you can use a method like + :meth:`~toga.App.about()` as a command handler. + + It can accept either a function or a coroutine. + + :param fn: The callable to invoke as a handler. + :returns: A handler that will invoke the callable. + """ + if inspect.iscoroutinefunction(fn): + + async def _handler(command, *args, **kwargs): + return await fn(*args, **kwargs) + + else: + + def _handler(command, *args, **kwargs): + return fn(*args, **kwargs) + + return _handler def wrapped_handler( @@ -116,7 +143,7 @@ def wrapped_handler( def _handler(*args: object, **kwargs: object) -> object: if asyncio.iscoroutinefunction(handler): - asyncio.ensure_future( + return asyncio.ensure_future( handler_with_cleanup(handler, cleanup, interface, *args, **kwargs) ) else: @@ -127,7 +154,7 @@ def _handler(*args: object, **kwargs: object) -> object: traceback.print_exc() else: if inspect.isgenerator(result): - asyncio.ensure_future( + return asyncio.ensure_future( long_running_task(interface, result, cleanup) ) else: @@ -138,7 +165,7 @@ def _handler(*args: object, **kwargs: object) -> object: except Exception as e: print("Error in handler cleanup:", e, file=sys.stderr) traceback.print_exc() - return None + return result _handler._raw = handler diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index fb2ef5f9fd..8ab0b258a6 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -237,6 +237,10 @@ def test_create( metadata_mock.assert_called_once_with(expected_app_name) + # Preferences menu item exists, but is disabled + assert toga.Command.PREFERENCES in app.commands + assert not app.commands[toga.Command.PREFERENCES].enabled + @pytest.mark.parametrize( "kwargs, exc_type, message", @@ -462,7 +466,7 @@ def test_startup_method(event_loop): def startup_assertions(app): # At time startup is invoked, there should be an app command installed - assert len(app.commands) == 1 + assert len(app.commands) == 4 return toga.Box() startup = Mock(side_effect=startup_assertions) @@ -476,8 +480,8 @@ def startup_assertions(app): assert_action_performed(app, "create App commands") startup.assert_called_once_with(app) assert_action_performed(app, "create App menus") - # There is only 1 menu item - the app command - assert app._impl.n_menu_items == 1 + # 4 menu items have been created + assert len(app.commands) == 4 def test_startup_subclass(event_loop): @@ -488,7 +492,7 @@ def startup(self): self.main_window = toga.MainWindow() # At time startup is invoked, there should be an app command installed - assert len(self.commands) == 1 + assert len(self.commands) == 4 # Add an extra user command self.commands.add(toga.Command(None, "User command")) @@ -500,8 +504,8 @@ def startup(self): assert_action_performed(app, "create App commands") assert_action_performed(app, "create App menus") - # 2 menu items have been created - assert app._impl.n_menu_items == 2 + # 5 menu items have been created + assert app._impl.n_menu_items == 5 def test_startup_subclass_no_main_window(event_loop): diff --git a/core/tests/app/test_customized_app.py b/core/tests/app/test_customized_app.py new file mode 100644 index 0000000000..5ca1ea30b1 --- /dev/null +++ b/core/tests/app/test_customized_app.py @@ -0,0 +1,62 @@ +import asyncio +from unittest.mock import Mock + +import pytest + +import toga + + +class CustomizedApp(toga.App): + def startup(self): + self.main_window = toga.MainWindow() + + self._preferences = Mock() + + def preferences(self): + self._preferences() + + +class AsyncCustomizedApp(CustomizedApp): + # A custom app where preferences and document-management commands are user-defined + # as async handlers. + + async def preferences(self): + self._preferences() + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_create(event_loop, AppClass): + """An app with overridden commands can be created""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + + assert custom_app.formal_name == "Custom App" + assert custom_app.app_id == "org.beeware.customized-app" + assert custom_app.app_name == "customized-app" + assert custom_app.on_exit._raw is None + + # Preferences exist and are enabled + assert toga.Command.PREFERENCES in custom_app.commands + assert custom_app.commands[toga.Command.PREFERENCES].enabled + + +@pytest.mark.parametrize( + "AppClass", + [ + CustomizedApp, + AsyncCustomizedApp, + ], +) +def test_preferences_menu(event_loop, AppClass): + """The custom preferences method is activated by the preferences menu""" + custom_app = AppClass("Custom App", "org.beeware.customized-app") + + result = custom_app.commands[toga.Command.PREFERENCES].action() + if asyncio.isfuture(result): + custom_app.loop.run_until_complete(result) + custom_app._preferences.assert_called_once_with() diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index 708f37f06f..f0e686c990 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -75,6 +75,7 @@ def test_change_action(): assert cmd.group == toga.Group.COMMANDS assert cmd.section == 0 assert cmd.order == 0 + assert cmd.id.startswith("cmd-") assert cmd.action._raw == action1 # Change the action to a something new @@ -101,6 +102,7 @@ def test_create_explicit(app): group=grp, section=3, order=4, + id="slartibartfast", ) assert cmd.text == "Test command" @@ -109,6 +111,7 @@ def test_create_explicit(app): assert cmd.group == grp assert cmd.section == 3 assert cmd.order == 4 + assert cmd.id == "slartibartfast" assert cmd.action._raw == handler diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py index b3bc6c7ec9..dc2e4a81e3 100644 --- a/core/tests/command/test_commandset.py +++ b/core/tests/command/test_commandset.py @@ -127,6 +127,53 @@ def test_add_clear_with_app(app, change_handler): assert list(app.commands) == [cmd_a, cmd1b, cmd2, cmd1a, cmd_b] +def test_retrieve_by_id(app): + """Commands can be retrieved by ID.""" + + # Put some extra commands into the app + cmd_a = toga.Command(None, text="App command a") + cmd_b = toga.Command(None, text="App command b", id="custom-command-b") + cmd_c = toga.Command(None, text="App command C", id="custom-command-c") + + app.commands.add(cmd_a, cmd_b) + + # Retrieve the custom command by ID. + assert "custom-command-b" in app.commands + assert cmd_b in app.commands + assert app.commands["custom-command-b"] == cmd_b + + # Look up a command that *hasn't* been added to the app + assert "custom-command-c" not in app.commands + assert cmd_c not in app.commands + with pytest.raises(KeyError): + app.commands["custom-command-c"] + + # Check a system installed command + assert toga.Command.ABOUT in app.commands + assert app.commands[toga.Command.ABOUT].text == "About Test App" + + +def test_default_command_ordering(app): + """The default app commands are in a known order.""" + + assert [ + ( + obj.id + if isinstance(obj, toga.Command) + else f"---{obj.group.text}---" if isinstance(obj, Separator) else "?" + ) + for obj in app.commands + ] == [ + # App menu + toga.Command.PREFERENCES, + "---*---", + toga.Command.EXIT, + # Help menu + toga.Command.ABOUT, + toga.Command.VISIT_HOMEPAGE, + ] + + def test_ordering( parent_group_1, parent_group_2, diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index e5a6d547b7..1568c19052 100644 --- a/core/tests/test_handlers.py +++ b/core/tests/test_handlers.py @@ -3,7 +3,7 @@ import pytest -from toga.handlers import AsyncResult, NativeHandler, wrapped_handler +from toga.handlers import AsyncResult, NativeHandler, simple_handler, wrapped_handler class ExampleAsyncResult(AsyncResult): @@ -18,8 +18,8 @@ def test_noop_handler(): assert wrapped._raw is None - # This does nothing, but doesn't raise an error. - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + # This does nothing, but doesn't raise an error, and returns None + assert wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) is None def test_noop_handler_with_cleanup(): @@ -31,8 +31,8 @@ def test_noop_handler_with_cleanup(): assert wrapped._raw is None - # This does nothing, but doesn't raise an error. - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + # This does nothing, but doesn't raise an error, and returns None + assert wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) is None # Cleanup method was invoked cleanup.assert_called_once_with(obj, None) @@ -47,8 +47,8 @@ def test_noop_handler_with_cleanup_error(capsys): assert wrapped._raw is None - # This does nothing, but doesn't raise an error. - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + # This does nothing, but doesn't raise an error, and returns None + assert wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) is None # Cleanup method was invoked cleanup.assert_called_once_with(obj, None) @@ -68,14 +68,15 @@ def test_function_handler(): def handler(*args, **kwargs): handler_call["args"] = args handler_call["kwargs"] = kwargs + return 42 wrapped = wrapped_handler(obj, handler) # Raw handler is the original function assert wrapped._raw == handler - # Invoke wrapper - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + # Invoke wrapper; handler return value is preserved + assert wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) == 42 # Handler arguments are as expected. assert handler_call == { @@ -98,8 +99,8 @@ def handler(*args, **kwargs): assert wrapped._raw == handler - # Invoke handler. The exception is swallowed - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + # Invoke handler. The exception is swallowed; return value is None + assert wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) is None # Handler arguments are as expected. assert handler_call == { @@ -131,7 +132,7 @@ def handler(*args, **kwargs): assert wrapped._raw == handler # Invoke handler - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + assert wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) == 42 # Handler arguments are as expected. assert handler_call == { @@ -159,8 +160,8 @@ def handler(*args, **kwargs): # Raw handler is the original function assert wrapped._raw == handler - # Invoke handler. The exception in cleanup is swallowed - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) + # Invoke handler. The error in cleanup is swallowed. + assert wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) == 42 # Handler arguments are as expected. assert handler_call == { @@ -190,21 +191,17 @@ def handler(*args, **kwargs): handler_call["slept"] = True yield # A yield without a sleep handler_call["done"] = True + return 42 wrapped = wrapped_handler(obj, handler) # Raw handler is the original generator assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler, and run until it is complete. + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42 + ) # Handler arguments are as expected. assert handler_call == { @@ -231,15 +228,11 @@ def handler(*args, **kwargs): # Raw handler is the original generator assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler; return value is None due to exception + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) + is None + ) # Handler arguments are as expected. assert handler_call == { @@ -274,15 +267,10 @@ def handler(*args, **kwargs): # Raw handler is the original generator assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42 + ) # Handler arguments are as expected. assert handler_call == { @@ -316,15 +304,10 @@ def handler(*args, **kwargs): # Raw handler is the original generator assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler; error in cleanup is swallowed + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42 + ) # Handler arguments are as expected. assert handler_call == { @@ -354,21 +337,17 @@ async def handler(*args, **kwargs): handler_call["kwargs"] = kwargs await asyncio.sleep(0.01) # A short sleep handler_call["done"] = True + return 42 wrapped = wrapped_handler(obj, handler) # Raw handler is the original coroutine assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42 + ) # Handler arguments are as expected. assert handler_call == { @@ -394,15 +373,11 @@ async def handler(*args, **kwargs): # Raw handler is the original coroutine assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler; return value is None due to exception + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) + is None + ) # Handler arguments are as expected. assert handler_call == { @@ -435,15 +410,10 @@ async def handler(*args, **kwargs): # Raw handler is the original coroutine assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42 + ) # Handler arguments are as expected. assert handler_call == { @@ -474,15 +444,10 @@ async def handler(*args, **kwargs): # Raw handler is the original coroutine assert wrapped._raw == handler - # Invoke wrapper inside an active run loop. - async def waiter(): - wrapped("arg1", "arg2", kwarg1=3, kwarg2=4) - count = 0 - while not handler_call.get("done", False) and count < 5: - await asyncio.sleep(0.01) - count += 1 - - event_loop.run_until_complete(waiter()) + # Invoke the handler; error in cleanup is swallowed + assert ( + event_loop.run_until_complete(wrapped("arg1", "arg2", kwarg1=3, kwarg2=4)) == 42 + ) # Handler arguments are as expected. assert handler_call == { @@ -612,6 +577,55 @@ def test_async_exception_cancelled(event_loop): event_loop.run_until_complete(result.future) +def test_simple_handler_function(): + """A function can be wrapped as a simple handler.""" + handler_call = {} + + def handler(*args, **kwargs): + handler_call["args"] = args + handler_call["kwargs"] = kwargs + return 42 + + wrapped = simple_handler(handler) + + # Invoke the handler as if it were a method handler (i.e., with the extra "widget" + # argument) + assert wrapped("obj", "arg1", "arg2", kwarg1=3, kwarg2=4) == 42 + + # The "widget" bound argument has been dropped + assert handler_call == { + "args": ("arg1", "arg2"), + "kwargs": {"kwarg1": 3, "kwarg2": 4}, + } + + +def test_simple_handler_coroutine(event_loop): + """A coroutine can be wrapped as a simple handler.""" + handler_call = {} + + async def handler(*args, **kwargs): + handler_call["args"] = args + handler_call["kwargs"] = kwargs + return 42 + + wrapped = simple_handler(handler) + + # Invoke the handler as if it were a coroutine method handler (i.e., with the extra + # "widget" argument) + assert ( + event_loop.run_until_complete( + wrapped("obj", "arg1", "arg2", kwarg1=3, kwarg2=4) + ) + == 42 + ) + + # The "widget" bound argument has been dropped + assert handler_call == { + "args": ("arg1", "arg2"), + "kwargs": {"kwarg1": 3, "kwarg2": 4}, + } + + ###################################################################### # 2023-12: Backwards compatibility ###################################################################### diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index 44505248dc..5f67c7433d 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -8,7 +8,7 @@ The top-level representation of an application. :header-rows: 1 :file: ../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Application|Component))'} + :include: {0: '^Application$'} Usage @@ -70,6 +70,10 @@ details, along with many of the other constructor arguments, as packaging metada format compatible with :any:`importlib.metadata`. If you deploy your app with `Briefcase `__, this will be done automatically. +A Toga app will install a number of default commands to reflect core application +functionality (such as the Quit/Exit menu item, and the About menu item). The IDs for +these commands are defined as constants on the :class:`~toga.Command` class. + Notes ----- diff --git a/docs/reference/api/containers/box.rst b/docs/reference/api/containers/box.rst index e2f79747cb..0869688821 100644 --- a/docs/reference/api/containers/box.rst +++ b/docs/reference/api/containers/box.rst @@ -8,7 +8,7 @@ A generic container for other widgets. Used to construct layouts. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Box|Component))'} + :include: {0: '^Box$'} Usage ----- diff --git a/docs/reference/api/hardware/camera.rst b/docs/reference/api/hardware/camera.rst index a1a95bd598..321e789ead 100644 --- a/docs/reference/api/hardware/camera.rst +++ b/docs/reference/api/hardware/camera.rst @@ -8,7 +8,7 @@ A sensor that can capture photos and/or video. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Camera|Hardware))'} + :include: {0: '^Camera$'} Usage ----- diff --git a/docs/reference/api/hardware/location.rst b/docs/reference/api/hardware/location.rst index 9d888d93eb..a5b0cabb88 100644 --- a/docs/reference/api/hardware/location.rst +++ b/docs/reference/api/hardware/location.rst @@ -8,7 +8,7 @@ A sensor that can capture the geographical location of the device. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Location|Hardware))'} + :include: {0: '^Location$'} Usage ----- diff --git a/docs/reference/api/hardware/screens.rst b/docs/reference/api/hardware/screens.rst index 2047c6bb43..50b047306a 100644 --- a/docs/reference/api/hardware/screens.rst +++ b/docs/reference/api/hardware/screens.rst @@ -8,7 +8,7 @@ A representation of a screen attached to a device. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Screen|Device)$)'} + :include: {0: '^Screen$'} Usage ----- diff --git a/docs/reference/api/resources/app_paths.rst b/docs/reference/api/resources/app_paths.rst index 12fa3e48d0..fb8d2afebd 100644 --- a/docs/reference/api/resources/app_paths.rst +++ b/docs/reference/api/resources/app_paths.rst @@ -9,7 +9,7 @@ application. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(App Paths|Component)$)'} + :include: {0: '^App Paths$'} Usage ----- diff --git a/docs/reference/api/resources/command.rst b/docs/reference/api/resources/command.rst index 9f2067bfcb..86d3ace56e 100644 --- a/docs/reference/api/resources/command.rst +++ b/docs/reference/api/resources/command.rst @@ -8,7 +8,7 @@ A representation of app functionality that the user can invoke from menus or too :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Command|Component))'} + :include: {0: '^Command$'} Usage @@ -73,6 +73,10 @@ as well. It isn't possible to have functionality exposed on a toolbar that isn't also exposed by the app. So, ``cmd2`` will be added to the app, even though it wasn't explicitly added to the app commands. +Each command has an :attr:`~toga.Command.id` attribute. This is set when the command is +defined; if no ID is provided, a random ID will be generated for the Command. This +identifier can be used to retrieve a command from :any:`toga.App.commands` and +:any:`toga.Window.toolbar`. Reference --------- @@ -83,4 +87,8 @@ Reference .. autoclass:: toga.Group :exclude-members: key +.. autoclass:: toga.command.CommandSet + +.. autoclass:: toga.command.Separator + .. autoprotocol:: toga.command.ActionHandler diff --git a/docs/reference/api/resources/fonts.rst b/docs/reference/api/resources/fonts.rst index 86bd1b35ff..9b73ec36df 100644 --- a/docs/reference/api/resources/fonts.rst +++ b/docs/reference/api/resources/fonts.rst @@ -8,7 +8,7 @@ A font for displaying text. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Font|Component))'} + :include: {0: '^Font$'} Usage ----- diff --git a/docs/reference/api/resources/icons.rst b/docs/reference/api/resources/icons.rst index 3f517d4ef7..a96c6a8a37 100644 --- a/docs/reference/api/resources/icons.rst +++ b/docs/reference/api/resources/icons.rst @@ -8,7 +8,7 @@ A small, square image, used to provide easily identifiable visual context to a w :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Icon|Component))'} + :include: {0: '^Icon$'} Usage ----- diff --git a/docs/reference/api/resources/images.rst b/docs/reference/api/resources/images.rst index 498d2ccee8..05ed9eb9bd 100644 --- a/docs/reference/api/resources/images.rst +++ b/docs/reference/api/resources/images.rst @@ -8,7 +8,7 @@ Graphical content of arbitrary size. :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!(Image|Component)$)'} + :include: {0: '^Image$'} Usage ----- diff --git a/docs/reference/api/widgets/widget.rst b/docs/reference/api/widgets/widget.rst index 51c1162470..b14bb81aea 100644 --- a/docs/reference/api/widgets/widget.rst +++ b/docs/reference/api/widgets/widget.rst @@ -8,7 +8,7 @@ The abstract base class of all widgets. This class should not be be instantiated :header-rows: 1 :file: ../../data/widgets_by_platform.csv :included_cols: 4,5,6,7,8,9,10 - :exclude: {0: '(?!^(Widget|Component)$)'} + :include: {0: '^Widget$'} Reference diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index a9708f5f16..02a03d18f9 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -2,7 +2,9 @@ import sys from pathlib import Path -from toga.command import Command +from toga.app import overridden +from toga.command import Command, Group +from toga.handlers import simple_handler from .screens import Screen as ScreenImpl from .utils import LoggedObject @@ -30,8 +32,34 @@ def create_app_commands(self): self._action("create App commands") self.interface.commands.add( Command( - None, + simple_handler(self.interface.preferences), + "Preferences", + group=Group.APP, + # For now, only enable preferences if the user defines an implementation + enabled=overridden(self.interface.preferences), + id=Command.PREFERENCES, + ), + # Invoke `on_exit` rather than `exit`, because we want to trigger the "OK to + # exit?" logic. It's already a bound handler, so we can use it directly. + Command( + self.interface.on_exit, + "Exit", + group=Group.APP, + section=sys.maxsize, + id=Command.EXIT, + ), + Command( + simple_handler(self.interface.about), f"About {self.interface.formal_name}", + group=Group.HELP, + id=Command.ABOUT, + ), + Command( + simple_handler(self.interface.visit_homepage), + "Visit homepage", + enabled=self.interface.home_page is not None, + group=Group.HELP, + id=Command.VISIT_HOMEPAGE, ), ) diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 169e631623..fcf7e9868f 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -7,8 +7,9 @@ import gbulb import toga -from toga import App as toga_App +from toga.app import App as toga_App, overridden from toga.command import Command, Separator +from toga.handlers import simple_handler from .keys import gtk_accel from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk @@ -84,27 +85,42 @@ def gtk_startup(self, data=None): # Commands and menus ###################################################################### - def _menu_about(self, command, **kwargs): - self.interface.about() - - def _menu_quit(self, command, **kwargs): - self.interface.on_exit() - def create_app_commands(self): self.interface.commands.add( + # ---- App menu ----------------------------------- + # Include a preferences menu item; but only enable it if the user has + # overridden it in their App class. Command( - self._menu_about, - "About " + self.interface.formal_name, - group=toga.Group.HELP, + simple_handler(self.interface.preferences), + "Preferences", + group=toga.Group.APP, + enabled=overridden(self.interface.preferences), + id=Command.PREFERENCES, ), - Command(None, "Preferences", group=toga.Group.APP), - # Quit should always be the last item, in a section on its own + # Quit should always be the last item, in a section on its own. Invoke + # `on_exit` rather than `exit`, because we want to trigger the "OK to exit?" + # logic. It's already a bound handler, so we can use it directly. Command( - self._menu_quit, + self.interface.on_exit, "Quit " + self.interface.formal_name, shortcut=toga.Key.MOD_1 + "q", group=toga.Group.APP, section=sys.maxsize, + id=Command.EXIT, + ), + # ---- Help menu ----------------------------------- + Command( + simple_handler(self.interface.about), + "About " + self.interface.formal_name, + group=toga.Group.HELP, + id=Command.ABOUT, + ), + Command( + simple_handler(self.interface.visit_homepage), + "Visit homepage", + enabled=self.interface.home_page is not None, + group=toga.Group.HELP, + id=Command.VISIT_HOMEPAGE, ), ) @@ -278,7 +294,7 @@ class DocumentApp(App): # pragma: no cover def create_app_commands(self): super().create_app_commands() self.interface.commands.add( - toga.Command( + Command( self.open_file, text="Open...", shortcut=toga.Key.MOD_1 + "o", diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 2798debc84..6fe9a6b682 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -67,10 +67,17 @@ async def test_device_rotation(app, app_probe): # Desktop platform tests #################################################################################### - async def test_exit_on_close_main_window(app, main_window_probe, mock_app_exit): + async def test_exit_on_close_main_window( + monkeypatch, + app, + main_window_probe, + mock_app_exit, + ): """An app can be exited by closing the main window""" + # Rebind the exit command to the on_exit handler. on_exit_handler = Mock(return_value=False) app.on_exit = on_exit_handler + monkeypatch.setattr(app.commands[toga.Command.EXIT], "_action", app.on_exit) # Close the main window main_window_probe.close() @@ -90,10 +97,12 @@ async def test_exit_on_close_main_window(app, main_window_probe, mock_app_exit): on_exit_handler.assert_called_once_with(app) mock_app_exit.assert_called_once_with() - async def test_menu_exit(app, app_probe, mock_app_exit): + async def test_menu_exit(monkeypatch, app, app_probe, mock_app_exit): """An app can be exited by using the menu item""" + # Rebind the exit command to the on_exit handler. on_exit_handler = Mock(return_value=False) app.on_exit = on_exit_handler + monkeypatch.setattr(app.commands[toga.Command.EXIT], "_action", app.on_exit) # Close the main window app_probe.activate_menu_exit() @@ -470,10 +479,14 @@ async def test_menu_about(monkeypatch, app, app_probe): async def test_menu_visit_homepage(monkeypatch, app, app_probe): """The visit homepage menu item can be used""" - # We don't actually want to open a web browser; just check that the interface method - # was invoked. - visit_homepage = Mock() - monkeypatch.setattr(app, "visit_homepage", visit_homepage) + # If the backend defines a VISIT_HOMEPAGE command, mock the visit_homepage method, + # and rebind the visit homepage command to the visit_homepage method. + if toga.Command.VISIT_HOMEPAGE in app.commands: + visit_homepage = Mock() + monkeypatch.setattr(app, "visit_homepage", visit_homepage) + monkeypatch.setattr( + app.commands[toga.Command.VISIT_HOMEPAGE], "_action", app.visit_homepage + ) app_probe.activate_menu_visit_homepage() diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index c06623cf50..620b2171bd 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -1,5 +1,7 @@ import toga -from toga.command import Separator +from toga.app import overridden +from toga.command import Command, Group, Separator +from toga.handlers import simple_handler from toga_web.libs import create_element, js from toga_web.window import Window @@ -32,15 +34,20 @@ def create_app_commands(self): self.interface.commands.add( # ---- Help menu ---------------------------------- - toga.Command( - self._menu_about, + Command( + simple_handler(self.interface.about), "About " + formal_name, - group=toga.Group.HELP, + group=Group.HELP, + id=Command.ABOUT, ), - toga.Command( - None, + # Include a preferences menu item; but only enable it if the user has + # overridden it in their App class. + Command( + simple_handler(self.interface.preferences), "Preferences", - group=toga.Group.HELP, + group=Group.HELP, + enabled=overridden(self.interface.preferences), + id=Command.PREFERENCES, ), ) diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index c4b39dbf60..f14789c0ec 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -11,9 +11,10 @@ from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher -import toga from toga import Key -from toga.command import Separator +from toga.app import overridden +from toga.command import Command, Group, Separator +from toga.handlers import simple_handler from .keys import toga_to_winforms_key, toga_to_winforms_shortcut from .libs.proactor import WinformsProactorEventLoop @@ -155,31 +156,41 @@ def create(self): def create_app_commands(self): self.interface.commands.add( - # About should be the last item in the Help menu, in a section on its own. - toga.Command( - lambda _: self.interface.about(), - f"About {self.interface.formal_name}", - group=toga.Group.HELP, - section=sys.maxsize, + # ---- File menu ----------------------------------- + # Include a preferences menu item; but only enable it if the user has + # overridden it in their App class. + Command( + simple_handler(self.interface.preferences), + "Preferences", + group=Group.FILE, + enabled=overridden(self.interface.preferences), + id=Command.PREFERENCES, ), - # - toga.Command(None, "Preferences", group=toga.Group.FILE), - # - # On Windows, the Exit command doesn't usually contain the app name. It - # should be the last item in the File menu, in a section on its own. - toga.Command( - lambda _: self.interface.on_exit(), + # Exit should always be the last item, in a section on its own. Invoke + # `on_exit` rather than `exit`, because we want to trigger the "OK to exit?" + # logic. It's already a bound handler, so we can use it directly. + Command( + self.interface.on_exit, "Exit", shortcut=Key.MOD_1 + "q", - group=toga.Group.FILE, + group=Group.FILE, section=sys.maxsize, + id=Command.EXIT, ), - # - toga.Command( - lambda _: self.interface.visit_homepage(), + # ---- Help menu ----------------------------------- + Command( + simple_handler(self.interface.visit_homepage), "Visit homepage", enabled=self.interface.home_page is not None, - group=toga.Group.HELP, + group=Group.HELP, + id=Command.VISIT_HOMEPAGE, + ), + Command( + simple_handler(self.interface.about), + f"About {self.interface.formal_name}", + group=Group.HELP, + section=sys.maxsize, + id=Command.ABOUT, ), ) @@ -218,7 +229,7 @@ def create_menus(self): # The File menu should come before all user-created menus. self._menu_groups = {} - toga.Group.FILE.order = -1 + Group.FILE.order = -1 submenu = None for cmd in self.interface.commands: @@ -383,11 +394,11 @@ class DocumentApp(App): # pragma: no cover def create_app_commands(self): super().create_app_commands() self.interface.commands.add( - toga.Command( + Command( lambda w: self.open_file, text="Open...", shortcut=Key.MOD_1 + "o", - group=toga.Group.FILE, + group=Group.FILE, section=0, ), )