diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 4bc9325e5c..b3633fa054 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -195,9 +195,9 @@ def create(self): # The `_listener` listens for activity event callbacks. For simplicity, # the app's `.native` is the listener's native Java class. self._listener = TogaApp(self) + # Call user code to populate the main window self.interface._startup() - self.create_app_commands() ###################################################################### # Commands and menus diff --git a/changes/2619.bugfix.rst b/changes/2619.bugfix.rst new file mode 100644 index 0000000000..5e221112cc --- /dev/null +++ b/changes/2619.bugfix.rst @@ -0,0 +1 @@ +The order of creation of system-level commands is now consistent between platforms, and menu creation is deferred until the user's startup method has been invoked. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index cf68318509..d367b9c68b 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -151,17 +151,13 @@ def create(self): self.appDelegate.native = self.native self.native.setDelegate_(self.appDelegate) - self.create_app_commands() + # Create the lookup table for menu items + self._menu_groups = {} + self._menu_items = {} # Call user code to populate the main window self.interface._startup() - # Create the lookup table of menu items, - # then force the creation of the menus. - self._menu_groups = {} - self._menu_items = {} - self.create_menus() - ###################################################################### # Commands and menus ###################################################################### diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 79e972ca44..65ab6fb08d 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -484,11 +484,9 @@ def __init__( self._full_screen_windows: tuple[Window, ...] | None = None + # Create the implementation. This will trigger any startup logic. self._create_impl() - # Now that we have an impl, set the on_change handler for commands - self.commands.on_change = self._impl.create_menus - def _create_impl(self) -> None: self.factory.App(interface=self) @@ -636,11 +634,21 @@ def _verify_startup(self) -> None: ) def _startup(self) -> None: + # App commands are created before the startup method so that the user's + # code has the opportunity to remove/change the default commands. + self._impl.create_app_commands() + # This is a wrapper around the user's startup method that performs any # post-setup validation. self.startup() self._verify_startup() + # Manifest the initial state of the menus. + self._impl.create_menus() + + # Now that we have a finalized impl, set the on_change handler for commands + self.commands.on_change = self._impl.create_menus + def startup(self) -> None: """Create and show the main window for the application. diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index d9677028a6..fb2ef5f9fd 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -459,14 +459,25 @@ def test_show_hide_cursor(app): def test_startup_method(event_loop): """If an app provides a startup method, it will be invoked during startup.""" - startup = Mock() + + def startup_assertions(app): + # At time startup is invoked, there should be an app command installed + assert len(app.commands) == 1 + return toga.Box() + + startup = Mock(side_effect=startup_assertions) + app = toga.App( formal_name="Test App", app_id="org.example.test", startup=startup, ) + 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 def test_startup_subclass(event_loop): @@ -476,11 +487,22 @@ class SubclassedApp(toga.App): 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 + + # Add an extra user command + self.commands.add(toga.Command(None, "User command")) + app = SubclassedApp(formal_name="Test App", app_id="org.example.test") # The main window will exist, and will have the app's formal name. assert app.main_window.title == "Test App" + 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 + def test_startup_subclass_no_main_window(event_loop): """If a subclassed app doesn't define a main window, an error is raised.""" diff --git a/core/tests/command/test_commandset.py b/core/tests/command/test_commandset.py index cf76b21108..b3bc6c7ec9 100644 --- a/core/tests/command/test_commandset.py +++ b/core/tests/command/test_commandset.py @@ -27,6 +27,9 @@ def test_create_with_values(): @pytest.mark.parametrize("change_handler", [(None), (Mock())]) def test_add_clear(app, change_handler): """Commands can be added and removed from a commandset.""" + # Make sure the app commands are clear to start with. + app.commands.clear() + # Put some commands into the app cmd_a = toga.Command(None, text="App command a") cmd_b = toga.Command(None, text="App command b", order=10) @@ -68,6 +71,9 @@ def test_add_clear(app, change_handler): @pytest.mark.parametrize("change_handler", [(None), (Mock())]) def test_add_clear_with_app(app, change_handler): """Commands can be added and removed from a commandset that is linked to an app.""" + # Make sure the app commands are clear to start with. + app.commands.clear() + # Put some commands into the app cmd_a = toga.Command(None, text="App command a") cmd_b = toga.Command(None, text="App command b", order=10) diff --git a/core/tests/window/test_window.py b/core/tests/window/test_window.py index 073846b0b8..eba2c70abe 100644 --- a/core/tests/window/test_window.py +++ b/core/tests/window/test_window.py @@ -144,26 +144,30 @@ def test_title(window, value, expected): def test_toolbar_implicit_add(window, app): """Adding an item to a toolbar implicitly adds it to the app.""" + # Clear the app commands to start with + app.commands.clear() + assert list(window.toolbar) == [] + assert list(app.commands) == [] + cmd1 = toga.Command(None, "Command 1") cmd2 = toga.Command(None, "Command 2") - toolbar = window.toolbar - assert list(toolbar) == [] + assert list(window.toolbar) == [] assert list(app.commands) == [] # Adding a command to the toolbar automatically adds it to the app - toolbar.add(cmd1) - assert list(toolbar) == [cmd1] + window.toolbar.add(cmd1) + assert list(window.toolbar) == [cmd1] assert list(app.commands) == [cmd1] # But not vice versa app.commands.add(cmd2) - assert list(toolbar) == [cmd1] + assert list(window.toolbar) == [cmd1] assert list(app.commands) == [cmd1, cmd2] # Adding a command to both places does not cause a duplicate app.commands.add(cmd1) - assert list(toolbar) == [cmd1] + assert list(window.toolbar) == [cmd1] assert list(app.commands) == [cmd1, cmd2] diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index ea71978b0c..a9708f5f16 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -2,6 +2,8 @@ import sys from pathlib import Path +from toga.command import Command + from .screens import Screen as ScreenImpl from .utils import LoggedObject from .window import Window @@ -24,8 +26,18 @@ def create(self): self._action("create App") self.interface._startup() + def create_app_commands(self): + self._action("create App commands") + self.interface.commands.add( + Command( + None, + f"About {self.interface.formal_name}", + ), + ) + def create_menus(self): self._action("create App menus") + self.n_menu_items = len(self.interface.commands) def main_loop(self): print("Starting app using Dummy backend.") diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index b0889ab11f..169e631623 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -61,15 +61,8 @@ def gtk_activate(self, data=None): pass def gtk_startup(self, data=None): - # Set up the default commands for the interface. - self.create_app_commands() - self.interface._startup() - # Create the lookup table of menu items, - # then force the creation of the menus. - self.create_menus() - # Now that we have menus, make the app take responsibility for # showing the menubar. # This is required because of inconsistencies in how the Gnome diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index d3945c0d2f..7988968e96 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -73,6 +73,10 @@ def create(self): # Commands and menus ###################################################################### + def create_app_commands(self): + # No menus on an iOS app (for now) + pass + def create_menus(self): # No menus on an iOS app (for now) pass diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index 1e643df268..2745058834 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -37,6 +37,9 @@ def create(self): # Commands and menus ###################################################################### + def create_app_commands(self): + pass + def create_menus(self): pass diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index bc9b7f72d9..c06623cf50 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -20,6 +20,14 @@ def create(self): # self.resource_path = os.path.dirname(os.path.dirname(NSBundle.mainBundle.bundlePath)) self.native = js.document.getElementById("app-placeholder") + # Call user code to populate the main window + self.interface._startup() + + ###################################################################### + # Commands and menus + ###################################################################### + + def create_app_commands(self): formal_name = self.interface.formal_name self.interface.commands.add( @@ -36,18 +44,6 @@ def create(self): ), ) - # Create the menus. This is done before main window content to ensure - # the
for the menubar is inserted before the
for the - # main window. - self.create_menus() - - # Call user code to populate the main window - self.interface._startup() - - ###################################################################### - # Commands and menus - ###################################################################### - def _create_submenu(self, group, items): submenu = create_element( "sl-dropdown", @@ -140,7 +136,7 @@ def create_menus(self): if old_menubar: old_menubar.replaceWith(self.menubar) else: - self.native.append(self.menubar) + self.native.prepend(self.menubar) ###################################################################### # App lifecycle diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index c5cc73d3f0..c4b39dbf60 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -146,8 +146,7 @@ def create(self): # Call user code to populate the main window self.interface._startup() - self.create_app_commands() - self.create_menus() + self.interface.main_window._impl.set_app(self) ###################################################################### @@ -205,11 +204,6 @@ def _submenu(self, group, menubar): return submenu def create_menus(self): - if self.interface.main_window is None: # pragma: no branch - # The startup method may create commands before creating the window, so - # we'll call create_menus again after it returns. - return - window = self.interface.main_window._impl menubar = window.native.MainMenuStrip if menubar: