diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index de42deb374..542fb870d1 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -10,6 +10,7 @@ from java import dynamic_proxy from org.beeware.android import IPythonApp, MainActivity +import toga from toga.command import Command, Group, Separator from toga.dialogs import InfoDialog from toga.handlers import simple_handler @@ -183,6 +184,9 @@ def onPrepareOptionsMenu(self, menu): class App: + # Android apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -242,10 +246,13 @@ def set_icon(self, icon): pass # pragma: no cover def set_main_window(self, window): - # The default layout of an Android app includes a titlebar; a simple App then - # hides that titlebar. We know what type of app we have when the main window is - # set. - self.interface.main_window._impl.configure_titlebar() + if window is None or window == toga.App.BACKGROUND: + raise ValueError("Apps without main windows are not supported on Android") + else: + # The default layout of an Android app includes a titlebar; a simple App + # then hides that titlebar. We know what type of app we have when the main + # window is set. + self.interface.main_window._impl.configure_titlebar() ###################################################################### # App resources diff --git a/changes/2209.feature.rst b/changes/2209.feature.rst new file mode 100644 index 0000000000..8c08790d36 --- /dev/null +++ b/changes/2209.feature.rst @@ -0,0 +1 @@ +Toga can now define an app whose life cycle isn't tied to a single main window. diff --git a/changes/2653.bugfix.rst b/changes/2653.bugfix.rst new file mode 100644 index 0000000000..d97efde4a2 --- /dev/null +++ b/changes/2653.bugfix.rst @@ -0,0 +1 @@ +On Winforms, the window of an application that is set as the main window is no longer shown as a result of assigning the window as ``App.main_window``. diff --git a/changes/97.feature.rst b/changes/97.feature.rst new file mode 100644 index 0000000000..1f86c16505 --- /dev/null +++ b/changes/97.feature.rst @@ -0,0 +1 @@ +Toga can now define apps that persist in the background without having any open windows. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index c0a8d79f5c..dba0b5f05c 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -1,6 +1,5 @@ import asyncio import inspect -import os import sys from pathlib import Path from urllib.parse import unquote, urlparse @@ -28,6 +27,7 @@ NSAboutPanelOptionApplicationVersion, NSAboutPanelOptionVersion, NSApplication, + NSApplicationActivationPolicyAccessory, NSApplicationActivationPolicyRegular, NSBeep, NSBundle, @@ -108,6 +108,9 @@ def validateMenuItem_(self, sender) -> bool: class App: + # macOS apps persist when there are no windows open + CLOSE_ON_LAST_WINDOW = False + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -117,12 +120,7 @@ def __init__(self, interface): asyncio.set_event_loop_policy(EventLoopPolicy()) self.loop = asyncio.new_event_loop() - # Stimulate the build of the app - self.create() - - def create(self): self.native = NSApplication.sharedApplication - self.native.setActivationPolicy(NSApplicationActivationPolicyRegular) # The app icon been set *before* the app instance is created. However, we only # need to set the icon on the app if it has been explicitly defined; the default @@ -130,15 +128,13 @@ def create(self): if self.interface.icon._impl.path: self.set_icon(self.interface.icon) # pragma: no cover - self.resource_path = os.path.dirname( - os.path.dirname(NSBundle.mainBundle.bundlePath) - ) + self.resource_path = Path(NSBundle.mainBundle.bundlePath).parent.parent self.appDelegate = AppDelegate.alloc().init() self.appDelegate.impl = self self.appDelegate.interface = self.interface self.appDelegate.native = self.native - self.native.setDelegate_(self.appDelegate) + self.native.setDelegate(self.appDelegate) # Create the lookup table for menu items self._menu_groups = {} @@ -422,7 +418,10 @@ def set_icon(self, icon): self.native.setApplicationIconImage(None) def set_main_window(self, window): - pass + if window == toga.App.BACKGROUND: + self.native.setActivationPolicy(NSApplicationActivationPolicyAccessory) + else: + self.native.setActivationPolicy(NSApplicationActivationPolicyRegular) ###################################################################### # App resources diff --git a/cocoa/tests_backend/app.py b/cocoa/tests_backend/app.py index 6e2dde5dcd..1357bbdd27 100644 --- a/cocoa/tests_backend/app.py +++ b/cocoa/tests_backend/app.py @@ -233,6 +233,14 @@ def keystroke(self, combination): ) return toga_key(event) + async def restore_standard_app(self): + # If the tesbed app has been made a background app, it will no longer be + # active, which affects whether it can receive focus and input events. + # Make it active again. This won't happen immediately; allow for a short + # delay. + self.app._impl.native.activateIgnoringOtherApps(True) + await self.redraw("Restore to standard app", delay=0.1) + def _setup_alert_dialog_result(self, dialog, result): # Replace the dialog polling mechanism with an implementation that polls # 5 times, then returns the required result. diff --git a/core/src/toga/app.py b/core/src/toga/app.py index 1648233882..11ae799163 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -2,6 +2,7 @@ import asyncio import importlib.metadata +import inspect import signal import sys import warnings @@ -206,6 +207,13 @@ class App: _impl: Any _camera: Camera _location: Location + _main_window: Window | str | None + + #: A constant that can be used as the main window to indicate that an app will + #: run in the background without a main window. + BACKGROUND: str = "background app" + + _UNDEFINED: str = "
" def __init__( self, @@ -378,7 +386,7 @@ def __init__( self._startup_method = startup - self._main_window: MainWindow | None = None + self._main_window = App._UNDEFINED self._windows = WindowSet(self) self._full_screen_windows: tuple[Window, ...] | None = None @@ -516,35 +524,61 @@ def main_loop(self) -> None: self._impl.main_loop() @property - def main_window(self) -> MainWindow | None: - """The main window for the app.""" + def main_window(self) -> Window | str | None: + """The main window for the app. + + See :ref:`the documentation on assigning a main window ` + for values that can be used for this attribute. + """ + if self._main_window is App._UNDEFINED: + raise ValueError("Application has not set a main window.") + return self._main_window @main_window.setter - def main_window(self, window: MainWindow | None) -> None: - # The main window must be closable - if isinstance(window, Window) and not window.closable: - raise ValueError("The window used as the main window must be closable.") + def main_window(self, window: MainWindow | str | None) -> None: + if window is None or window is App.BACKGROUND or isinstance(window, Window): + # The main window must be closable + if isinstance(window, Window) and not window.closable: + raise ValueError("The window used as the main window must be closable.") + + old_window = self._main_window + self._main_window = window + try: + self._impl.set_main_window(window) + except Exception as e: + # If the main window could not be changed, revert to the previous value + # then reraise the exception + if old_window is not App._UNDEFINED: + self._main_window = old_window + raise e + else: + raise ValueError(f"Don't know how to use {window!r} as a main window.") - self._main_window = window - self._impl.set_main_window(window) + def _create_initial_windows(self): + # TODO: Create the initial windows for the app. - def _verify_startup(self) -> None: - if not isinstance(self.main_window, Window): - raise ValueError( - "Application does not have a main window. " - "Does your startup() method assign a value to self.main_window?" - ) + # Safety check: Do we have at least one window? + if len(self.app.windows) == 0 and self.main_window is None: + # macOS document-based apps are allowed to have no open windows. + if self.app._impl.CLOSE_ON_LAST_WINDOW: + raise ValueError("App doesn't define any initial windows.") def _startup(self) -> None: # Install the platform-specific app commands. This is done *before* startup so # the user's code has the opporuntity 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. + # Invoke the user's startup method (or the default implementation) self.startup() - self._verify_startup() + + # Validate that the startup requirements have been met. + # Accessing the main window attribute will raise an exception if the app hasn't + # defined a main window. + _ = self.main_window + + # Create any initial windows + self._create_initial_windows() # Manifest the initial state of the menus. This will cascade down to all # open windows if the platform has window-based menus. Then install the @@ -560,6 +594,17 @@ def _startup(self) -> None: window._impl.create_toolbar() window.toolbar.on_change = window._impl.create_toolbar + # Queue a task to run as soon as the event loop starts. + if inspect.iscoroutinefunction(self.running): + # running is a co-routine; create a sync wrapper + def on_app_running(): + asyncio.ensure_future(self.running()) + + else: + on_app_running = self.running + + self.loop.call_soon_threadsafe(on_app_running) + def startup(self) -> None: """Create and show the main window for the application. @@ -574,6 +619,15 @@ def startup(self) -> None: self.main_window.show() + def running(self) -> None: + """Logic to execute as soon as the main event loop is running. + + Override this method to add any logic you want to run as soon as the app's event + loop is running. + + If necessary, the overridden method can be defined as as an ``async`` coroutine. + """ + ###################################################################### # App resources ###################################################################### @@ -838,10 +892,6 @@ def __init__( def _create_impl(self) -> None: self.factory.DocumentApp(interface=self) - def _verify_startup(self) -> None: - # No post-startup validation required for DocumentApps - pass - @property def document_types(self) -> dict[str, type[Document]]: """The document types this app can manage. diff --git a/core/src/toga/documents.py b/core/src/toga/documents.py index c107896249..3858a2d593 100644 --- a/core/src/toga/documents.py +++ b/core/src/toga/documents.py @@ -35,7 +35,8 @@ def __init__( # Create a platform specific implementation of the Document self._impl = app.factory.Document(interface=self) - def can_close(self) -> bool: + # TODO: This will be covered when the document API is finalized + def can_close(self) -> bool: # pragma: no cover """Is the main document window allowed to close? The default implementation always returns ``True``; subclasses can override this @@ -46,7 +47,10 @@ def can_close(self) -> bool: """ return True - async def handle_close(self, window: Window, **kwargs: object) -> bool: + # TODO: This will be covered when the document API is finalized + async def handle_close( + self, window: Window, **kwargs: object + ) -> bool: # pragma: no cover """An ``on-close`` handler for the main window of this document that implements platform-specific document close behavior. diff --git a/core/src/toga/window.py b/core/src/toga/window.py index b1b6feaacc..e330304eb6 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -299,16 +299,31 @@ def close(self) -> None: undefined, except for :attr:`closed` which can be used to check if the window was closed. """ + close_window = True if self.app.main_window == self: # Closing the window marked as the main window is a request to exit. # Trigger on_exit handling, which may cause the window to close. self.app.on_exit() - else: - if self.content: - self.content.window = None - self.app.windows.discard(self) - self._impl.close() - self._closed = True + close_window = False + elif self.app.main_window is None: + # If this is an app without a main window, this is the last window in the + # app, and the platform exits on last window close, trigger an exit. + if len(self.app.windows) == 1 and self.app._impl.CLOSE_ON_LAST_WINDOW: + self.app.on_exit() + close_window = False + + if close_window: + self._close() + + def _close(self): + # The actual logic for closing a window. This is abstracted so that the testbed + # can monkeypatch this method, recording the close request without actually + # closing the app. + if self.content: + self.content.window = None + self.app.windows.discard(self) + self._impl.close() + self._closed = True @property def closed(self) -> bool: diff --git a/core/tests/app/test_app.py b/core/tests/app/test_app.py index 2a827e70d6..cd07a0c4b6 100644 --- a/core/tests/app/test_app.py +++ b/core/tests/app/test_app.py @@ -10,6 +10,7 @@ import toga from toga_dummy.utils import ( + EventLog, assert_action_not_performed, assert_action_performed, assert_action_performed_with, @@ -408,6 +409,52 @@ def test_no_current_window(app): assert app.current_window is None +def test_change_main_window(app): + """The main window value can be changed.""" + new_main = toga.Window() + + app.main_window = new_main + + assert app.main_window == new_main + assert_action_performed_with(app, "set_main_window", window=new_main) + + +def test_change_invalid_main_window(app): + """If the new main window value isn't valid, an exception is raised.""" + old_main = app.main_window + EventLog.reset() + + # Assign a main window value that will raise an exception + with pytest.raises( + ValueError, + match=r"Invalid dummy main window value", + ): + bad_window = toga.Window() + bad_window._invalid_main_window = True + app.main_window = bad_window + + # Main window hasn't changed. + assert app.main_window == old_main + assert_action_not_performed(app, "set_main_window") + + +def test_change_invalid_creation_main_window(event_loop): + """If the new main window value provided at creation isn't valid, an exception is raised.""" + + class BadMainWindowApp(toga.App): + def startup(self): + window = toga.MainWindow() + window._invalid_main_window = True + self.main_window = window + + # Creating an app with an invalid main window raises an exception. + with pytest.raises( + ValueError, + match=r"Invalid dummy main window value", + ): + BadMainWindowApp(formal_name="Test App", app_id="org.example.test") + + def test_full_screen(event_loop): """The app can be put into full screen mode.""" window1 = toga.Window() @@ -535,7 +582,18 @@ class SubclassedApp(toga.App): def startup(self): pass - with pytest.raises(ValueError, match=r"Application does not have a main window."): + with pytest.raises(ValueError, match=r"Application has not set a main window."): + SubclassedApp(formal_name="Test App", app_id="org.example.test") + + +def test_startup_subclass_unknown_main_window(event_loop): + """If a subclassed app uses an unknown main window type, an error is raised""" + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = 42 + + with pytest.raises(ValueError, match=r"Don't know how to use 42 as a main window"): SubclassedApp(formal_name="Test App", app_id="org.example.test") @@ -626,6 +684,36 @@ def test_exit_rejected_handler(app): on_exit_handler.assert_called_once_with(app) +def test_no_exit_last_window_close(app): + """Windows can be created and closed without closing the app.""" + # App has 1 window initially + assert len(app.windows) == 1 + + # Create a second, non-main window + window1 = toga.Window() + window1.content = toga.Box() + window1.show() + + window2 = toga.Window() + window2.content = toga.Box() + window2.show() + + # App has 3 windows + assert len(app.windows) == 3 + + # Close one of the secondary windows + window1.close() + + # Window has been closed, but the app hasn't exited. + assert len(app.windows) == 2 + assert_action_performed(window1, "close") + assert_action_not_performed(app, "exit") + + # Closing the MainWindow kills the app + app.main_window.close() + assert_action_performed(app, "exit") + + def test_loop(app, event_loop): """The main thread's event loop can be accessed.""" assert isinstance(app.loop, asyncio.AbstractEventLoop) @@ -651,6 +739,46 @@ async def waiter(): canary.assert_called_once() +def test_running(event_loop): + """The running() method is invoked when the main loop starts""" + running = {} + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = toga.MainWindow() + + def running(self): + running["called"] = True + + app = SubclassedApp(formal_name="Test App", app_id="org.example.test") + + # Run a fake main loop. + app.loop.run_until_complete(asyncio.sleep(0.5)) + + # The running method was invoked + assert running["called"] + + +def test_async_running_method(event_loop): + """The running() method can be a coroutine.""" + running = {} + + class SubclassedApp(toga.App): + def startup(self): + self.main_window = toga.MainWindow() + + async def running(self): + running["called"] = True + + app = SubclassedApp(formal_name="Test App", app_id="org.example.test") + + # Run a fake main loop. + app.loop.run_until_complete(asyncio.sleep(0.5)) + + # The running coroutine was invoked + assert running["called"] + + def test_deprecated_id(event_loop): """The deprecated `id` constructor argument is ignored, and the property of the same name is redirected to `app_id`""" diff --git a/core/tests/app/test_background_app.py b/core/tests/app/test_background_app.py new file mode 100644 index 0000000000..f0d9cd21fb --- /dev/null +++ b/core/tests/app/test_background_app.py @@ -0,0 +1,52 @@ +import pytest + +import toga +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, +) + + +class ExampleBackgroundApp(toga.App): + def startup(self): + self.main_window = toga.App.BACKGROUND + + +@pytest.fixture +def background_app(event_loop): + app = ExampleBackgroundApp( + "Test App", + "org.beeware.background-app", + ) + return app + + +def test_create(background_app): + """A background app can be created.""" + # App has been created + assert background_app._impl.interface == background_app + assert_action_performed(background_app, "create App") + + # App has no windows + assert len(background_app.windows) == 0 + + +def test_no_exit_last_window_close(background_app): + """Windows can be created and closed without closing the app.""" + # App has no windows initially + assert len(background_app.windows) == 0 + + window = toga.Window() + window.content = toga.Box() + window.show() + + # App has a window + assert len(background_app.windows) == 1 + + # Close the window + window.close() + + # Window has been closed, but the app hasn't exited. + assert len(background_app.windows) == 0 + assert_action_performed(window, "close") + assert_action_not_performed(background_app, "exit") diff --git a/core/tests/app/test_document_app.py b/core/tests/app/test_document_app.py new file mode 100644 index 0000000000..5e8b51c273 --- /dev/null +++ b/core/tests/app/test_document_app.py @@ -0,0 +1,213 @@ +import sys +from pathlib import Path + +import pytest + +import toga +from toga_dummy.app import App as DummyApp +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, +) + + +class ExampleDocument(toga.Document): + def __init__(self, path, app): + super().__init__(path=path, document_type="Example Document", app=app) + + def create(self): + self.main_window = toga.DocumentMainWindow(self) + + def read(self): + self.content = self.path + + +class ExampleDocumentApp(toga.DocumentApp): + def startup(self): + self.main_window = None + + +@pytest.fixture +def doc_app(event_loop): + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={ + # Register ExampleDocument + "foobar": ExampleDocument, + }, + ) + # The app will have a single window; set this window as the current window + # so that dialogs have something to hang off. + app.current_window = list(app.windows)[0] + return app + + +def test_create_no_cmdline(monkeypatch): + """A document app can be created with no command line.""" + monkeypatch.setattr(sys, "argv", ["app-exe"]) + + with pytest.raises( + ValueError, + match=r"App doesn't define any initial windows.", + ): + ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + + +def test_create_with_cmdline(monkeypatch): + """If a document is specified at the command line, it is opened.""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.foobar"]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + app.main_loop() + + assert app._impl.interface == app + assert_action_performed(app, "create DocumentApp") + + assert app.document_types == {"foobar": ExampleDocument} + assert len(app.documents) == 1 + assert isinstance(app.documents[0], ExampleDocument) + + # Document content has been read + assert app.documents[0].content == Path("/path/to/filename.foobar") + + # Document window has been created and shown + assert_action_performed(app.documents[0].main_window, "create DocumentMainWindow") + assert_action_performed(app.documents[0].main_window, "show") + + +def test_create_with_unknown_document_type(monkeypatch): + """If the document specified at the command line is an unknown type, an exception is + raised.""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.unknown"]) + + with pytest.raises( + ValueError, + match=r"Don't know how to open documents of type .unknown", + ): + ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + + +def test_create_no_document_type(): + """A document app must manage at least one document type.""" + with pytest.raises( + ValueError, + match=r"A document must manage at least one document type.", + ): + toga.DocumentApp("Test App", "org.beeware.document-app") + + +def test_create_no_windows_non_persistent(event_loop): + """Non-persistent apps must define at least one window in startup.""" + + class NoWindowApp(toga.App): + def startup(self): + self.main_window = None + + with pytest.raises( + ValueError, + match=r"App doesn't define any initial windows.", + ): + NoWindowApp(formal_name="Test App", app_id="org.example.test") + + +def test_create_no_windows_persistent(monkeypatch, event_loop): + """Persistent apps do not have to define windows during startup.""" + # Monkeypatch the property that makes the backend persistent + monkeypatch.setattr(DummyApp, "CLOSE_ON_LAST_WINDOW", False) + + class NoWindowApp(toga.App): + def startup(self): + self.main_window = None + + # We can create the app without an error + NoWindowApp(formal_name="Test App", app_id="org.example.test") + + +def test_close_last_document_non_persistent(monkeypatch): + """Non-persistent apps exit when the last document is closed""" + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/example.foobar"]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + # Create a second window + # TODO: Use the document interface for this + # app.open(other_file) + _ = toga.Window() + + # There are 2 open documents + # assert len(app.documents) == 2 + assert len(app.windows) == 2 + + # Close the first document window + list(app.windows)[0].close() + + # One document window closed. + # assert len(app.documents) == 1 + assert len(app.windows) == 1 + + # App hasn't exited + assert_action_not_performed(app, "exit") + + # Close the last remaining document window + list(app.windows)[0].close() + + # App has now exited + assert_action_performed(app, "exit") + + +def test_close_last_document_persistent(monkeypatch): + """Persistent apps don't exit when the last document is closed""" + # Monkeypatch the property that makes the backend persistent + monkeypatch.setattr(DummyApp, "CLOSE_ON_LAST_WINDOW", False) + + monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/example.foobar"]) + + app = ExampleDocumentApp( + "Test App", + "org.beeware.document-app", + document_types={"foobar": ExampleDocument}, + ) + # Create a second window + # TODO: Use the document interface for this + # app.open(other_file) + _ = toga.Window() + + # There are 2 open documents + # assert len(app.documents) == 2 + assert len(app.windows) == 2 + + # Close the first document window + list(app.windows)[0].close() + + # One document window closed. + # assert len(app.documents) == 1 + assert len(app.windows) == 1 + + # App hasn't exited + assert_action_not_performed(app, "exit") + + # Close the last remaining document window + list(app.windows)[0].close() + + # No document windows. + # assert len(app.documents) == 0 + assert len(app.windows) == 0 + + # App still hasn't exited + assert_action_not_performed(app, "exit") diff --git a/core/tests/app/test_documentapp.py b/core/tests/app/test_documentapp.py deleted file mode 100644 index 722d00fdbc..0000000000 --- a/core/tests/app/test_documentapp.py +++ /dev/null @@ -1,159 +0,0 @@ -import asyncio -import sys -from pathlib import Path -from unittest.mock import Mock - -import pytest - -import toga -from toga.platform import get_platform_factory -from toga_dummy.documents import Document as DummyDocument -from toga_dummy.utils import assert_action_performed - - -class ExampleDocument(toga.Document): - def __init__(self, path, app): - super().__init__(path=path, document_type="Example Document", app=app) - - def create(self): - self.main_window = toga.DocumentMainWindow(self) - - def read(self): - self.content = self.path - - -def test_create_no_cmdline(monkeypatch): - """A document app can be created with no command line.""" - monkeypatch.setattr(sys, "argv", ["app-exe"]) - - app = toga.DocumentApp( - "Test App", - "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, - ) - app.main_loop() - - assert app._impl.interface == app - assert_action_performed(app, "create DocumentApp") - - assert app.document_types == {"foobar": ExampleDocument} - assert app.documents == [] - - -def test_create_with_cmdline(monkeypatch): - """If a document is specified at the command line, it is opened.""" - monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.foobar"]) - - app = toga.DocumentApp( - "Test App", - "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, - ) - app.main_loop() - - assert app._impl.interface == app - assert_action_performed(app, "create DocumentApp") - - assert app.document_types == {"foobar": ExampleDocument} - assert len(app.documents) == 1 - assert isinstance(app.documents[0], ExampleDocument) - - # Document content has been read - assert app.documents[0].content == Path("/path/to/filename.foobar") - - # Document window has been created and shown - assert_action_performed(app.documents[0].main_window, "create DocumentMainWindow") - assert_action_performed(app.documents[0].main_window, "show") - - -def test_create_with_unknown_document_type(monkeypatch): - """If the document specified at the command line is an unknown type, an exception is - raised.""" - monkeypatch.setattr(sys, "argv", ["app-exe", "/path/to/filename.unknown"]) - - with pytest.raises( - ValueError, - match=r"Don't know how to open documents of type .unknown", - ): - toga.DocumentApp( - "Test App", - "org.beeware.document-app", - document_types={"foobar": ExampleDocument}, - ) - - -def test_create_no_document_type(): - """A document app must manage at least one document type.""" - with pytest.raises( - ValueError, - match=r"A document must manage at least one document type.", - ): - toga.DocumentApp("Test App", "org.beeware.document-app") - - -def test_close_single_document_app(): - """An app in single document mode closes the app when the window is closed.""" - # Monkeypatch the dummy impl to use single document mode - DummyDocument.SINGLE_DOCUMENT_APP = True - - # Mock the app, but preserve the factory - app = Mock() - app.factory = get_platform_factory() - - doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) - - # Window technically was prevented from closing, but the app has been exited. - # This must be run as a co-routine. - async def _do_close(): - return await doc.handle_close(Mock()) - - assert not asyncio.get_event_loop().run_until_complete(_do_close()) - app.exit.assert_called_once_with() - - -def test_close_multiple_document_app(): - """An app in multiple document mode doesn't close when the window is closed.""" - # Monkeypatch the dummy impl to use single document mode - DummyDocument.SINGLE_DOCUMENT_APP = False - - # Mock the app, but preserve the factory - app = Mock() - app.factory = get_platform_factory() - - doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) - - # Window has closed, but app has not exited. - # This must be run as a co-routine. - async def _do_close(): - return await doc.handle_close(Mock()) - - assert asyncio.get_event_loop().run_until_complete(_do_close()) - app.exit.assert_not_called() - - -@pytest.mark.parametrize("is_single_doc_app", [True, False]) -def test_no_close(monkeypatch, is_single_doc_app): - """A document can prevent itself from being closed.""" - # Monkeypatch the dummy impl to set the app mode - DummyDocument.SINGLE_DOCUMENT_APP = is_single_doc_app - - # Monkeypatch the Example document to prevent closing. - # Define this as a co-routine to simulate an implementation that called a dialog. - async def can_close(self): - return False - - ExampleDocument.can_close = can_close - - # Mock the app, but preserve the factory - app = Mock() - app.factory = get_platform_factory() - - doc = ExampleDocument(path=Path("/path/to/doc.txt"), app=app) - - # Window was prevented from closing. - # This must be run as a co-routine. - async def _do_close(): - await doc.handle_close(Mock()) - - assert not asyncio.get_event_loop().run_until_complete(_do_close()) - app.exit.assert_not_called() diff --git a/docs/reference/api/app.rst b/docs/reference/api/app.rst index ea6468e3d5..fa28bdbc0e 100644 --- a/docs/reference/api/app.rst +++ b/docs/reference/api/app.rst @@ -15,10 +15,9 @@ Usage ----- The App class is the top level representation of all application activity. It is a -singleton object - any given process can only have a single App. That -application may manage multiple windows, but it is guaranteed to have at least one -window (called the :attr:`~toga.App.main_window`); when the App's -:attr:`~toga.App.main_window` is closed, the application will exit. +singleton object - any given process can only have a single App. That application may +manage multiple windows, but it will generally have at least one window (called the +:attr:`~toga.App.main_window`). The application is started by calling :meth:`~toga.App.main_loop()`. This will invoke the :meth:`~toga.App.startup()` method of the app. @@ -46,8 +45,10 @@ that will be added to the main window of the app. This approach to app construction is most useful with simple apps. For most complex apps, you should subclass :class:`toga.App`, and provide an implementation of -:meth:`~toga.App.startup()`. This implementation *must* create and assign a -``main_window`` for the app. +:meth:`~toga.App.startup()`. This implementation *must* assign a value to +:attr:`~toga.App.main_window` for the app. The possible values are :ref:`discussed +below `; most apps will assign an instance of +:any:`toga.MainWindow`: .. code-block:: python @@ -77,21 +78,52 @@ commands are automatically installed *before* :meth:`~toga.App.startup()` is inv you wish to customize the menu items exposed by your app, you can add or remove commands in your :meth:`~toga.App.startup()` implementation. +.. _assigning-main-window: + Assigning a main window ----------------------- An app *must* assign ``main_window`` as part of the startup process. However, the value that is assigned as the main window will affect the behavior of the app. -Standard app -~~~~~~~~~~~~ +:class:`~toga.Window` +~~~~~~~~~~~~~~~~~~~~~ + +Most apps will assign an instance of :class:`toga.Window` (or a subclass, +such as :class:`toga.MainWindow`) as the main window. This window will control +the life cycle of the app. When the window assigned as the main window is closed, the +app will exit. + +If you create an ``App`` by passing a ``startup`` argument to the constructor, a +:class:`~toga.MainWindow` will be automatically created and assigned to ``main_window``. + +``None`` +~~~~~~~~ + +If your app doesn't have a single "main" window, but instead has multiple windows that +are equally important (e.g., a document editor, or a web browser), you can assign a +value of ``None`` to :attr:`~toga.App.main_window`. The resulting behavior is slightly +different on each platform, reflecting platform differences. + +On macOS, the app is allowed to continue running without having any open windows. The +app can open and close windows as required; the app will keep running until explicitly +exited. + +On Linux and Windows, when an app closes the last window it is managing, the app will +automatically exit. Attempting to close the last window will trigger any app-level +:meth:`~toga.App.on_exit` handling in addition to any window-specific +:meth:`~toga.Window.on_close` handling. + +Mobile, web and console platforms *must* define a main window. + +:attr:`~toga.App.BACKGROUND` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The most common type of app will assign a :any:`MainWindow` or :any:`toga.Window` -instance as the main window. This window controls the life cycle of the app; when the -main window is closed, the app will exit. +Assigning a value of :attr:`toga.App.BACKGROUND` as the main window will allow your app +to persist even if it doesn't have any open windows. It will also hide any app-level +icon from your taskbar. -This is the type of app that will be created if you use an instance of :any:`toga.App` -passing in a ``startup`` argument to the constructor. +Background apps are not supported on mobile, web and console platforms. Notes ----- diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 9cbc8a5ea4..ba7a982aaa 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -11,6 +11,9 @@ class App(LoggedObject): + # Dummy apps close on the last window close + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): super().__init__() self.interface = interface @@ -24,6 +27,10 @@ def create(self): self._action("create App") self.interface._startup() + ###################################################################### + # Commands and menus + ###################################################################### + def create_app_commands(self): self._action("create App commands") self.interface.commands.add( @@ -71,49 +78,27 @@ def create_menus(self): if hasattr(window._impl, "create_menus"): window._impl.create_menus() - def main_loop(self): - print("Starting app using Dummy backend.") - self._action("main loop") - - def set_icon(self, icon): - self._action("set_icon", icon=icon) - - def set_main_window(self, window): - self._action("set_main_window", window=window) - - def show_about_dialog(self): - self._action("show_about_dialog") - - def beep(self): - self._action("beep") + ###################################################################### + # App lifecycle + ###################################################################### def exit(self): self._action("exit") - def get_current_window(self): - try: - return self._get_value("current_window", self.interface.main_window._impl) - except AttributeError: - return None - - def set_current_window(self, window): - self._action("set_current_window", window=window) - self._set_value("current_window", window._impl) - - def enter_full_screen(self, windows): - self._action("enter_full_screen", windows=windows) - - def exit_full_screen(self, windows): - self._action("exit_full_screen", windows=windows) + def main_loop(self): + print("Starting app using Dummy backend.") + self._action("main loop") - def show_cursor(self): - self._action("show_cursor") + def set_main_window(self, window): + # If the window has been tagged as an invalid main window, raise an error. + if hasattr(window, "_invalid_main_window"): + raise ValueError("Invalid dummy main window value") - def hide_cursor(self): - self._action("hide_cursor") + self._action("set_main_window", window=window) - def simulate_exit(self): - self.interface.on_exit() + ###################################################################### + # App resources + ###################################################################### def get_screens(self): # _________________________________________________ @@ -142,14 +127,71 @@ def get_screens(self): ScreenImpl(native=("Secondary Screen", (-1366, -768), (1366, 768))), ] + def set_icon(self, icon): + self._action("set_icon", icon=icon) + + ###################################################################### + # App capabilities + ###################################################################### + + def beep(self): + self._action("beep") + + def show_about_dialog(self): + self._action("show_about_dialog") + + ###################################################################### + # Cursor control + ###################################################################### + + def hide_cursor(self): + self._action("hide_cursor") + + def show_cursor(self): + self._action("show_cursor") + + ###################################################################### + # Window control + ###################################################################### + + def get_current_window(self): + try: + main_window = self.interface.main_window._impl + except AttributeError: + main_window = None + + return self._get_value("current_window", main_window) + + def set_current_window(self, window): + self._action("set_current_window", window=window) + self._set_value("current_window", window._impl) + + ###################################################################### + # Full screen control + ###################################################################### + + def enter_full_screen(self, windows): + self._action("enter_full_screen", windows=windows) + + def exit_full_screen(self, windows): + self._action("exit_full_screen", windows=windows) + + ###################################################################### + # Simulation interface + ###################################################################### + + def simulate_exit(self): + self.interface.on_exit() + class DocumentApp(App): def create(self): self._action("create DocumentApp") - self.interface._startup() try: # Create and show the document instance self.interface._open(Path(sys.argv[1])) except IndexError: pass + + self.interface._startup() diff --git a/dummy/src/toga_dummy/utils.py b/dummy/src/toga_dummy/utils.py index e5c3a772ac..2cb1ce3358 100644 --- a/dummy/src/toga_dummy/utils.py +++ b/dummy/src/toga_dummy/utils.py @@ -173,7 +173,7 @@ def __init__(self, logtype, instance, **context): self.context = context def __repr__(self): - return f"=1.0.0", -] +supported = false [tool.briefcase.app.documentapp.android] -requires = [ - "../../android", -] - -base_theme = "Theme.MaterialComponents.Light.DarkActionBar" - -build_gradle_dependencies = [ - "com.google.android.material:material:1.11.0", -] +supported = false # Web deployment [tool.briefcase.app.documentapp.web] -requires = [ - "../../web", -] -style_framework = "Shoelace v2.3" +supported = false diff --git a/examples/statusiconapp/README.rst b/examples/statusiconapp/README.rst new file mode 100644 index 0000000000..43eacb0a8f --- /dev/null +++ b/examples/statusiconapp/README.rst @@ -0,0 +1,13 @@ +Status Icon App +=============== + +A demonstration of a App that resides in the background, placing an icon in the status +bar/tray of the operating system. + +Quickstart +~~~~~~~~~~ + +To run this example: + + $ pip install toga + $ python -m statusiconapp diff --git a/examples/statusiconapp/pyproject.toml b/examples/statusiconapp/pyproject.toml new file mode 100644 index 0000000000..11e0f4ff93 --- /dev/null +++ b/examples/statusiconapp/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["briefcase"] + +[tool.briefcase] +project_name = "Status Icon App" +bundle = "org.beeware.toga.examples" +version = "0.0.1" +url = "https://beeware.org" +license.file = "LICENSE" +author = 'Tiberius Yak' +author_email = "tiberius@beeware.org" + +[tool.briefcase.app.statusiconapp] +formal_name = "Status Icon App" +description = "A testing app" +sources = ['statusiconapp'] +requires = [ + '../../core', +] + + +[tool.briefcase.app.statusiconapp.macOS] +requires = [ + '../../cocoa', + 'std-nslog>=1.0.0', +] + +[tool.briefcase.app.statusiconapp.linux] +requires = [ + '../../gtk', +] + +[tool.briefcase.app.statusiconapp.windows] +requires = [ + '../../winforms', +] + +# Mobile deployments +[tool.briefcase.app.statusiconapp.iOS] +supported = false + +[tool.briefcase.app.statusiconapp.android] +supported = false + +# Web deployment +[tool.briefcase.app.statusiconapp.web] +supported = false diff --git a/examples/statusiconapp/statusiconapp/__init__.py b/examples/statusiconapp/statusiconapp/__init__.py new file mode 100644 index 0000000000..86826e638c --- /dev/null +++ b/examples/statusiconapp/statusiconapp/__init__.py @@ -0,0 +1,9 @@ +# Examples of valid version strings +# __version__ = '1.2.3.dev1' # Development release 1 +# __version__ = '1.2.3a1' # Alpha Release 1 +# __version__ = '1.2.3b1' # Beta Release 1 +# __version__ = '1.2.3rc1' # RC Release 1 +# __version__ = '1.2.3' # Final Release +# __version__ = '1.2.3.post1' # Post Release 1 + +__version__ = "0.0.1" diff --git a/examples/statusiconapp/statusiconapp/__main__.py b/examples/statusiconapp/statusiconapp/__main__.py new file mode 100644 index 0000000000..f13f1823f9 --- /dev/null +++ b/examples/statusiconapp/statusiconapp/__main__.py @@ -0,0 +1,4 @@ +from statusiconapp.app import main + +if __name__ == "__main__": + main().main_loop() diff --git a/examples/statusiconapp/statusiconapp/app.py b/examples/statusiconapp/statusiconapp/app.py new file mode 100644 index 0000000000..0892fc0107 --- /dev/null +++ b/examples/statusiconapp/statusiconapp/app.py @@ -0,0 +1,31 @@ +import asyncio + +import toga + + +class ExampleStatusIconApp(toga.App): + 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) + + async def running(self): + # Once the app is running, start a heartbeat + while True: + await asyncio.sleep(1) + print("Running...") + + +def main(): + return ExampleStatusIconApp( + "Status Icon App", "org.beeware.toga.examples.statusiconapp" + ) + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/examples/statusiconapp/statusiconapp/resources/README b/examples/statusiconapp/statusiconapp/resources/README new file mode 100644 index 0000000000..84f0abfa08 --- /dev/null +++ b/examples/statusiconapp/statusiconapp/resources/README @@ -0,0 +1 @@ +Put any icons or images in this directory. diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 0568c86e3f..a72eb04b0f 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -17,6 +17,9 @@ class App: + # GTK apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -24,9 +27,6 @@ def __init__(self, interface): gbulb.install(gtk=True) self.loop = asyncio.new_event_loop() - self.create() - - def create(self): # Stimulate the build of the app self.native = Gtk.Application( application_id=self.interface.app_id, @@ -36,6 +36,7 @@ def create(self): # Connect the GTK signal that will cause app startup to occur self.native.connect("startup", self.gtk_startup) + # Activate is a no-op, but GTK complains if you don't implement it self.native.connect("activate", self.gtk_activate) self.actions = None @@ -46,14 +47,6 @@ def gtk_activate(self, data=None): def gtk_startup(self, data=None): self.interface._startup() - # Now that we have menus, make the app take responsibility for - # showing the menubar. - # This is required because of inconsistencies in how the Gnome - # shell operates on different windowing environments; - # see #872 for details. - settings = Gtk.Settings.get_default() - settings.set_property("gtk-shell-shows-menubar", False) - # Set any custom styles css_provider = Gtk.CssProvider() css_provider.load_from_data(TOGA_DEFAULT_STYLES) @@ -171,6 +164,14 @@ def create_menus(self): # Set the menu for the app. self.native.set_menubar(menubar) + # Now that we have menus, make the app take responsibility for + # showing the menubar. + # This is required because of inconsistencies in how the Gnome + # shell operates on different windowing environments; + # see #872 for details. + settings = Gtk.Settings.get_default() + settings.set_property("gtk-shell-shows-menubar", False) + ###################################################################### # App lifecycle ###################################################################### @@ -183,8 +184,15 @@ def main_loop(self): # Modify signal handlers to make sure Ctrl-C is caught and handled. signal.signal(signal.SIGINT, signal.SIG_DFL) + # Retain a reference to the app so that no-window apps can exist + self.native.hold() + self.loop.run_forever(application=self.native) + # Release the reference to the app. This can't be invoked by the testbed, + # because it's after the `run_forever()` that runs the testbed. + self.native.release() # pragma: no cover + def set_icon(self, icon): for window in self.interface.windows: window._impl.native.set_icon(icon._impl.native(72)) diff --git a/gtk/tests_backend/app.py b/gtk/tests_backend/app.py index 63afd92d25..5c525e9d80 100644 --- a/gtk/tests_backend/app.py +++ b/gtk/tests_backend/app.py @@ -207,3 +207,7 @@ def keystroke(self, combination): event.state = state return toga_key(event) + + async def restore_standard_app(self): + # No special handling needed to restore standard app. + await self.redraw("Restore to standard app") diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 96cec278f2..6ce828acd8 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -4,6 +4,7 @@ from rubicon.objc import objc_method from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle +import toga from toga.command import Command from toga.handlers import simple_handler from toga_iOS.libs import UIResponder, UIScreen, av_foundation @@ -51,6 +52,9 @@ def application_didChangeStatusBarOrientation_( class App: + # iOS apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -105,7 +109,8 @@ def set_icon(self, icon): pass # pragma: no cover def set_main_window(self, window): - pass + if window is None or window == toga.App.BACKGROUND: + raise ValueError("Apps without main windows are not supported on iOS") ###################################################################### # App resources diff --git a/iOS/src/toga_iOS/container.py b/iOS/src/toga_iOS/container.py index c5c4b7454b..002143bba6 100644 --- a/iOS/src/toga_iOS/container.py +++ b/iOS/src/toga_iOS/container.py @@ -154,6 +154,7 @@ def __init__( def height(self): # pragma: no cover return self.layout_native.bounds.size.height - self.top_offset + # The testbed app won't instantiate a simple app, so we can't test these properties @property def top_offset(self): # pragma: no cover return UIApplication.sharedApplication.statusBarFrame.size.height @@ -202,10 +203,6 @@ def __init__( # Set the controller's view to be the root content widget self.content_controller.view = self.native - @property - def height(self): - return self.layout_native.bounds.size.height - self.top_offset - @property def top_offset(self): return ( diff --git a/testbed/tests/app/conftest.py b/testbed/tests/app/conftest.py new file mode 100644 index 0000000000..874c6a83f8 --- /dev/null +++ b/testbed/tests/app/conftest.py @@ -0,0 +1,25 @@ +from unittest.mock import Mock + +import pytest + +import toga + + +@pytest.fixture +def mock_app_exit(monkeypatch, app): + # We can't actually exit during a test, so monkeypatch the exit call. + app_exit = Mock() + monkeypatch.setattr(toga.App, "exit", app_exit) + return app_exit + + +@pytest.fixture +def mock_main_window_close(monkeypatch, main_window): + # We need to prevent the main window from *actually* closing, so monkeypatch the + # method that implements the actual close. + + window_close = Mock() + + monkeypatch.setattr(main_window, "_close", window_close) + + return window_close diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 3b897a9190..8573cd34af 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -1,406 +1,6 @@ from unittest.mock import Mock -import pytest - import toga -from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE -from toga.style.pack import Pack - -from ..window.test_window import window_probe - - -@pytest.fixture -def mock_app_exit(monkeypatch, app): - # We can't actually exit during a test, so monkeypatch the exit met""" - app_exit = Mock() - monkeypatch.setattr(toga.App, "exit", app_exit) - return app_exit - - -# Mobile platforms have different windowing characteristics, so they have different tests. -if toga.platform.current_platform in {"iOS", "android"}: - #################################################################################### - # Mobile platform tests - #################################################################################### - - async def test_show_hide_cursor(app): - """The app cursor methods can be invoked""" - # Invoke the methods to verify the endpoints exist. However, they're no-ops, - # so there's nothing to test. - app.show_cursor() - app.hide_cursor() - - async def test_full_screen(app): - """Window can be made full screen""" - # Invoke the methods to verify the endpoints exist. However, they're no-ops, - # so there's nothing to test. - app.set_full_screen(app.current_window) - app.exit_full_screen() - - async def test_current_window(app, main_window, app_probe, main_window_probe): - """The current window can be retrieved""" - assert app.current_window == main_window - - # Explicitly set the current window - app.current_window = main_window - await main_window_probe.wait_for_window("Main window is still current") - assert app.current_window == main_window - - async def test_app_lifecycle(app, app_probe): - """Application lifecycle can be exercised""" - app_probe.enter_background() - await app_probe.redraw("App pre-background logic has been invoked") - - app_probe.enter_foreground() - await app_probe.redraw("App restoration logic has been invoked") - - app_probe.terminate() - await app_probe.redraw("App pre-termination logic has been invoked") - - async def test_device_rotation(app, app_probe): - """App responds to device rotation""" - app_probe.rotate() - await app_probe.redraw("Device has been rotated") - -else: - #################################################################################### - # Desktop platform tests - #################################################################################### - - async def test_exit_on_close_main_window( - app, - main_window, - main_window_probe, - mock_app_exit, - ): - """An app can be exited by closing the main window""" - # Add an on_close handler to the main window, initially rejecting close. - on_close_handler = Mock(return_value=False) - main_window.on_close = on_close_handler - - # Set an on_exit for the app handler, initially rejecting exit. - on_exit_handler = Mock(return_value=False) - app.on_exit = on_exit_handler - - # Try to close the main window; rejected by window - main_window_probe.close() - await main_window_probe.redraw( - "Main window close requested; rejected by window" - ) - - # on_close_handler was invoked, rejecting the close. - on_close_handler.assert_called_once_with(main_window) - - # on_exit_handler was not invoked; so the app won't be closed - on_exit_handler.assert_not_called() - mock_app_exit.assert_not_called() - - # Reset and try again, this time allowing the close - on_close_handler.reset_mock() - on_close_handler.return_value = True - on_exit_handler.reset_mock() - - # Close the main window; rejected by app - main_window_probe.close() - await main_window_probe.redraw("Main window close requested; rejected by app") - - # on_close_handler was invoked, allowing the close - on_close_handler.assert_called_once_with(main_window) - - # on_exit_handler was invoked, rejecting the close; so the app won't be closed - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_not_called() - - # Reset and try again, this time allowing the exit - on_close_handler.reset_mock() - on_exit_handler.reset_mock() - on_exit_handler.return_value = True - - # Close the main window; this will succeed - main_window_probe.close() - await main_window_probe.redraw("Main window close requested; accepted") - - # on_close_handler was invoked, allowing the close - on_close_handler.assert_called_once_with(main_window) - - # on_exit_handler was invoked and accepted, so the mocked exit() was called. - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_called_once_with() - - 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() - await app_probe.redraw("Exit selected from menu, but rejected") - - # on_exit_handler was invoked, rejecting the close; so the app won't be closed - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_not_called() - - # Reset and try again, this time allowing the exit - on_exit_handler.reset_mock() - on_exit_handler.return_value = True - app_probe.activate_menu_exit() - await app_probe.redraw("Exit selected from menu, and accepted") - - # on_exit_handler was invoked and accepted, so the mocked exit() was called. - on_exit_handler.assert_called_once_with(app) - mock_app_exit.assert_called_once_with() - - async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) - - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - - window1.show() - window2.show() - window3.show() - - app.current_window = window2 - - await app_probe.redraw("Extra windows added") - - app_probe.activate_menu_close_window() - await app_probe.redraw("Window 2 closed") - - assert window2 not in app.windows - - app_probe.activate_menu_close_all_windows() - await app_probe.redraw("All windows closed") - - # Close all windows will attempt to close the main window as well. - # This would be an app exit, but we can't allow that; so, the only - # window that *actually* remains will be the main window. - mock_app_exit.assert_called_once_with() - assert window1 not in app.windows - assert window2 not in app.windows - assert window3 not in app.windows - - await app_probe.redraw("Extra windows closed") - - # Now that we've "closed" all the windows, we're in a state where there - # aren't any windows. Patch get_current_window to reflect this. - monkeypatch.setattr( - app._impl, - "get_current_window", - Mock(return_value=None), - ) - app_probe.activate_menu_close_window() - await app_probe.redraw("No windows; Close Window is a no-op") - - app_probe.activate_menu_minimize() - await app_probe.redraw("No windows; Minimize is a no-op") - - finally: - if window1 in app.windows: - window1.close() - if window2 in app.windows: - window2.close() - if window3 in app.windows: - window3.close() - - async def test_menu_minimize(app, app_probe): - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window1.show() - - window1_probe = window_probe(app, window1) - - app.current_window = window1 - await app_probe.redraw("Extra window added") - - app_probe.activate_menu_minimize() - - await window1_probe.wait_for_window("Extra window minimized", minimize=True) - assert window1_probe.is_minimized - finally: - window1.close() - - async def test_full_screen(app, app_probe): - """Window can be made full screen""" - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window1_probe = window_probe(app, window1) - window2_probe = window_probe(app, window2) - - window1.show() - window2.show() - await app_probe.redraw("Extra windows are visible") - - assert not app.is_full_screen - assert not app_probe.is_full_screen(window1) - assert not app_probe.is_full_screen(window2) - initial_content1_size = app_probe.content_size(window1) - initial_content2_size = app_probe.content_size(window2) - - # Make window 2 full screen via the app - app.set_full_screen(window2) - await window2_probe.wait_for_window( - "Second extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert app_probe.is_full_screen(window2) - assert app_probe.content_size(window2)[0] > 1000 - assert app_probe.content_size(window2)[1] > 700 - - # Make window 1 full screen via the app, window 2 no longer full screen - app.set_full_screen(window1) - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 700 - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Exit full screen - app.exit_full_screen() - await window1_probe.wait_for_window( - "No longer full screen", - full_screen=True, - ) - - assert not app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Go full screen again on window 1 - app.set_full_screen(window1) - # A longer delay to allow for genie animations - await window1_probe.wait_for_window( - "First extra window is full screen", - full_screen=True, - ) - assert app.is_full_screen - - assert app_probe.is_full_screen(window1) - assert app_probe.content_size(window1)[0] > 1000 - assert app_probe.content_size(window1)[1] > 700 - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - # Exit full screen by passing no windows - app.set_full_screen() - - await window1_probe.wait_for_window( - "No longer full screen", - full_screen=True, - ) - assert not app.is_full_screen - - assert not app_probe.is_full_screen(window1) - assert app_probe.content_size(window1) == initial_content1_size - - assert not app_probe.is_full_screen(window2) - assert app_probe.content_size(window2) == initial_content2_size - - finally: - window1.close() - window2.close() - - async def test_show_hide_cursor(app, app_probe): - """The app cursor can be hidden and shown""" - assert app_probe.is_cursor_visible - app.hide_cursor() - await app_probe.redraw("Cursor is hidden") - assert not app_probe.is_cursor_visible - - # Hiding again can't make it more hidden - app.hide_cursor() - await app_probe.redraw("Cursor is still hidden") - assert not app_probe.is_cursor_visible - - # Show the cursor again - app.show_cursor() - await app_probe.redraw("Cursor is visible") - assert app_probe.is_cursor_visible - - # Showing again can't make it more visible - app.show_cursor() - await app_probe.redraw("Cursor is still visible") - assert app_probe.is_cursor_visible - - async def test_current_window(app, app_probe, main_window): - """The current window can be retrieved.""" - try: - if app_probe.supports_current_window_assignment: - assert app.current_window == main_window - - # When all windows are hidden, WinForms and Cocoa return None, while GTK - # returns the last active window. - main_window.hide() - assert app.current_window in [None, main_window] - - main_window.show() - assert app.current_window == main_window - finally: - main_window.show() - - window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) - window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) - window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) - - try: - window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) - window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) - window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) - - # We don't need to probe anything window specific; we just need - # a window probe to enforce appropriate delays. - window1_probe = window_probe(app, window1) - - window1.show() - window2.show() - window3.show() - - await window1_probe.wait_for_window("Extra windows added") - - app.current_window = window2 - await window1_probe.wait_for_window("Window 2 is current") - if app_probe.supports_current_window_assignment: - assert app.current_window == window2 - - app.current_window = window3 - await window1_probe.wait_for_window("Window 3 is current") - if app_probe.supports_current_window_assignment: - assert app.current_window == window3 - - # app_probe.platform tests? - finally: - window1.close() - window2.close() - window3.close() async def test_main_window_toolbar(app, main_window, main_window_probe): diff --git a/testbed/tests/app/test_desktop.py b/testbed/tests/app/test_desktop.py new file mode 100644 index 0000000000..744ff0602f --- /dev/null +++ b/testbed/tests/app/test_desktop.py @@ -0,0 +1,492 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.colors import CORNFLOWERBLUE, FIREBRICK, REBECCAPURPLE +from toga.style.pack import Pack + +from ..window.test_window import window_probe + +#################################################################################### +# Desktop platform tests +#################################################################################### +if toga.platform.current_platform not in {"macOS", "windows", "linux"}: + pytest.skip("Test is specific to desktop platforms", allow_module_level=True) + + +async def test_exit_on_close_main_window( + app, + main_window, + main_window_probe, + mock_app_exit, +): + """An app can be exited by closing the main window""" + # Add an on_close handler to the main window, initially rejecting close. + on_close_handler = Mock(return_value=False) + main_window.on_close = on_close_handler + + # Set an on_exit for the app handler, initially rejecting exit. + on_exit_handler = Mock(return_value=False) + app.on_exit = on_exit_handler + + # Try to close the main window; rejected by window + main_window_probe.close() + await main_window_probe.redraw("Main window close requested; rejected by window") + + # on_close_handler was invoked, rejecting the close. + on_close_handler.assert_called_once_with(main_window) + + # on_exit_handler was not invoked; so the app won't be closed + on_exit_handler.assert_not_called() + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the close + on_close_handler.reset_mock() + on_close_handler.return_value = True + on_exit_handler.reset_mock() + + # Close the main window; rejected by app + main_window_probe.close() + await main_window_probe.redraw("Main window close requested; rejected by app") + + # on_close_handler was invoked, allowing the close + on_close_handler.assert_called_once_with(main_window) + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_close_handler.reset_mock() + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + + # Close the main window; this will succeed + main_window_probe.close() + await main_window_probe.redraw("Main window close requested; accepted") + + # on_close_handler was invoked, allowing the close + on_close_handler.assert_called_once_with(main_window) + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() + + +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() + await app_probe.redraw("Exit selected from menu, but rejected") + + # on_exit_handler was invoked, rejecting the close; so the app won't be closed + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_not_called() + + # Reset and try again, this time allowing the exit + on_exit_handler.reset_mock() + on_exit_handler.return_value = True + app_probe.activate_menu_exit() + await app_probe.redraw("Exit selected from menu, and accepted") + + # on_exit_handler was invoked and accepted, so the mocked exit() was called. + on_exit_handler.assert_called_once_with(app) + mock_app_exit.assert_called_once_with() + + +async def test_menu_close_windows(monkeypatch, app, app_probe, mock_app_exit): + """Windows can be closed by a menu item""" + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + + try: + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + window1.show() + window2.show() + window3.show() + + app.current_window = window2 + + await app_probe.redraw("Extra windows added") + + app_probe.activate_menu_close_window() + await app_probe.redraw("Window 2 closed") + + assert window2 not in app.windows + + app_probe.activate_menu_close_all_windows() + await app_probe.redraw("All windows closed") + + # Close all windows will attempt to close the main window as well. + # This would be an app exit, but we can't allow that; so, the only + # window that *actually* remains will be the main window. + mock_app_exit.assert_called_once_with() + assert window1 not in app.windows + assert window2 not in app.windows + assert window3 not in app.windows + + await app_probe.redraw("Extra windows closed") + + # Now that we've "closed" all the windows, we're in a state where there + # aren't any windows. Patch get_current_window to reflect this. + monkeypatch.setattr( + app._impl, + "get_current_window", + Mock(return_value=None), + ) + app_probe.activate_menu_close_window() + await app_probe.redraw("No windows; Close Window is a no-op") + + app_probe.activate_menu_minimize() + await app_probe.redraw("No windows; Minimize is a no-op") + + finally: + if window1 in app.windows: + window1.close() + if window2 in app.windows: + window2.close() + if window3 in app.windows: + window3.close() + + +async def test_menu_minimize(app, app_probe): + """Windows can be minimized by a menu item""" + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + + try: + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window1.show() + + window1_probe = window_probe(app, window1) + + app.current_window = window1 + await app_probe.redraw("Extra window added") + + app_probe.activate_menu_minimize() + + await window1_probe.wait_for_window("Extra window minimized", minimize=True) + assert window1_probe.is_minimized + finally: + window1.close() + + +async def test_full_screen(app, app_probe): + """Window can be made full screen""" + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + + try: + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window1_probe = window_probe(app, window1) + window2_probe = window_probe(app, window2) + + window1.show() + window2.show() + await app_probe.redraw("Extra windows are visible") + + assert not app.is_full_screen + assert not app_probe.is_full_screen(window1) + assert not app_probe.is_full_screen(window2) + initial_content1_size = app_probe.content_size(window1) + initial_content2_size = app_probe.content_size(window2) + + # Make window 2 full screen via the app + app.set_full_screen(window2) + await window2_probe.wait_for_window( + "Second extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert app_probe.is_full_screen(window2) + assert app_probe.content_size(window2)[0] > 1000 + assert app_probe.content_size(window2)[1] > 700 + + # Make window 1 full screen via the app, window 2 no longer full screen + app.set_full_screen(window1) + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 700 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen + app.exit_full_screen() + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Go full screen again on window 1 + app.set_full_screen(window1) + # A longer delay to allow for genie animations + await window1_probe.wait_for_window( + "First extra window is full screen", + full_screen=True, + ) + assert app.is_full_screen + + assert app_probe.is_full_screen(window1) + assert app_probe.content_size(window1)[0] > 1000 + assert app_probe.content_size(window1)[1] > 700 + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + # Exit full screen by passing no windows + app.set_full_screen() + + await window1_probe.wait_for_window( + "No longer full screen", + full_screen=True, + ) + assert not app.is_full_screen + + assert not app_probe.is_full_screen(window1) + assert app_probe.content_size(window1) == initial_content1_size + + assert not app_probe.is_full_screen(window2) + assert app_probe.content_size(window2) == initial_content2_size + + finally: + window1.close() + window2.close() + + +async def test_show_hide_cursor(app, app_probe): + """The app cursor can be hidden and shown""" + assert app_probe.is_cursor_visible + app.hide_cursor() + await app_probe.redraw("Cursor is hidden") + assert not app_probe.is_cursor_visible + + # Hiding again can't make it more hidden + app.hide_cursor() + await app_probe.redraw("Cursor is still hidden") + assert not app_probe.is_cursor_visible + + # Show the cursor again + app.show_cursor() + await app_probe.redraw("Cursor is visible") + assert app_probe.is_cursor_visible + + # Showing again can't make it more visible + app.show_cursor() + await app_probe.redraw("Cursor is still visible") + assert app_probe.is_cursor_visible + + +async def test_current_window(app, app_probe, main_window): + """The current window can be retrieved""" + try: + if app_probe.supports_current_window_assignment: + assert app.current_window == main_window + + # When all windows are hidden, WinForms and Cocoa return None, while GTK + # returns the last active window. + main_window.hide() + assert app.current_window in [None, main_window] + + main_window.show() + assert app.current_window == main_window + finally: + main_window.show() + + window1 = toga.Window("Test Window 1", position=(150, 150), size=(200, 200)) + window2 = toga.Window("Test Window 2", position=(400, 150), size=(200, 200)) + window3 = toga.Window("Test Window 3", position=(300, 400), size=(200, 200)) + + try: + window1.content = toga.Box(style=Pack(background_color=REBECCAPURPLE)) + window2.content = toga.Box(style=Pack(background_color=CORNFLOWERBLUE)) + window3.content = toga.Box(style=Pack(background_color=FIREBRICK)) + + # We don't need to probe anything window specific; we just need + # a window probe to enforce appropriate delays. + window1_probe = window_probe(app, window1) + + window1.show() + window2.show() + window3.show() + + await window1_probe.wait_for_window("Extra windows added") + + app.current_window = window2 + await window1_probe.wait_for_window("Window 2 is current") + if app_probe.supports_current_window_assignment: + assert app.current_window == window2 + + app.current_window = window3 + await window1_probe.wait_for_window("Window 3 is current") + if app_probe.supports_current_window_assignment: + assert app.current_window == window3 + + # app_probe.platform tests? + finally: + window1.close() + window2.close() + window3.close() + + +async def test_session_based_app( + app, + app_probe, + main_window, + mock_app_exit, + mock_main_window_close, +): + """A desktop app can be converted into a session-based app.""" + # Set an on_exit for the app handler, allowing exit. + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + # Create and show a secondary window + secondary_window = toga.Window() + secondary_window.show() + + try: + # Change the app to a session-based app + app.main_window = None + await app_probe.redraw("App converted to session-based app") + + # Try to close the main window. This is monkeypatched, so it + # will record whether a close was allowed, but won't actually + # close or remove the window, so the window will still exist. + main_window.close() + await app_probe.redraw("Simulate close of main window; app should not exit") + + # The main window will be closed + mock_main_window_close.assert_called_once() + mock_main_window_close.reset_mock() + + # The app will *not* have been prompted to exit, because there + # is still an open window, and this is a session-based app. + on_exit_handler.assert_not_called() + on_exit_handler.reset_mock() + + # Close the secondary window. + secondary_window.close() + secondary_window = None + await app_probe.redraw("Secondary window has been closed") + + # Try to close the main window again. This time, the main + # window is the last open window; the backend's session behavior + # defines whether the app exits. + main_window.close() + if app._impl.CLOSE_ON_LAST_WINDOW: + await app_probe.redraw("Simulate close of main window; app should exit") + + # The platform closes the session on the last window close. + # Exit should have been called. + on_exit_handler.assert_called_once() + + # Main window will not be closed, because that will be + # superseded by the app exiting. + mock_main_window_close.assert_not_called() + else: + await app_probe.redraw("Simulate close of main window; app should not exit") + + # The platform persists the app when the last window closes. + # Exit should *not* have been called. + on_exit_handler.assert_not_called() + + # However, the the main window will be closed + mock_main_window_close.assert_called_once() + + finally: + app.main_window = main_window + await app_probe.restore_standard_app() + + if secondary_window: + secondary_window.close() + + +async def test_background_app( + app, + app_probe, + main_window, + mock_app_exit, + mock_main_window_close, +): + """A desktop app can be turned into a background app.""" + # Set an on_exit for the app handler, allowing exit. + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + # Create and show a secondary window + secondary_window = toga.Window() + secondary_window.show() + + try: + # Change the app to a background app + app.main_window = toga.App.BACKGROUND + await app_probe.redraw("App converted to background app") + + # Try to close the main window. This is monkeypatched, so it + # will record whether a close was allowed, but won't actually + # close or remove the window, so the window will still exist. + main_window.close() + await app_probe.redraw("Simulate close of main window; app should not exit") + + # The main window will be closed + mock_main_window_close.assert_called_once() + mock_main_window_close.reset_mock() + + # The app will *not* have been prompted to exit, because this is a + # background app, which can exist without windows. + on_exit_handler.assert_not_called() + on_exit_handler.reset_mock() + + # Close the secondary window. + secondary_window.close() + secondary_window = None + await app_probe.redraw("Secondary window has been closed") + + # Try to close the main window again. This time, the main window is the last + # open window; but the app will persist because this is a background app, + # which doesn't need a window to exist. + main_window.close() + await app_probe.redraw("Simulate close of main window; app should not exit") + + # The platform persists the app when the last window closes. + # Exit should *not* have been called. + on_exit_handler.assert_not_called() + + # Regardless, the main window will be closed + mock_main_window_close.assert_called_once() + + finally: + app.main_window = main_window + await app_probe.restore_standard_app() + + if secondary_window: + secondary_window.close() diff --git a/testbed/tests/app/test_mobile.py b/testbed/tests/app/test_mobile.py new file mode 100644 index 0000000000..e4f0c004b4 --- /dev/null +++ b/testbed/tests/app/test_mobile.py @@ -0,0 +1,71 @@ +import pytest + +import toga + +#################################################################################### +# Mobile platform tests +#################################################################################### +if toga.platform.current_platform not in {"iOS", "android"}: + pytest.skip("Test is specific to desktop platforms", allow_module_level=True) + + +async def test_show_hide_cursor(app): + """The app cursor methods can be invoked""" + # Invoke the methods to verify the endpoints exist. However, they're no-ops, + # so there's nothing to test. + app.show_cursor() + app.hide_cursor() + + +async def test_full_screen(app): + """Window can be made full screen""" + # Invoke the methods to verify the endpoints exist. However, they're no-ops, + # so there's nothing to test. + app.set_full_screen(app.current_window) + app.exit_full_screen() + + +async def test_current_window(app, main_window, main_window_probe): + """The current window can be retrieved""" + assert app.current_window == main_window + + # Explicitly set the current window + app.current_window = main_window + await main_window_probe.wait_for_window("Main window is still current") + assert app.current_window == main_window + + +async def test_app_lifecycle(app, app_probe): + """Application lifecycle can be exercised""" + app_probe.enter_background() + await app_probe.redraw("App pre-background logic has been invoked") + + app_probe.enter_foreground() + await app_probe.redraw("App restoration logic has been invoked") + + app_probe.terminate() + await app_probe.redraw("App pre-termination logic has been invoked") + + +async def test_device_rotation(app, app_probe): + """App responds to device rotation""" + app_probe.rotate() + await app_probe.redraw("Device has been rotated") + + +async def test_session_based_app(app): + """A mobile app can't be turned into a session-based app""" + with pytest.raises( + ValueError, + match=r"Apps without main windows are not supported on .*", + ): + app.main_window = None + + +async def test_background_app(app): + """A mobile app can't be turned into a background app""" + with pytest.raises( + ValueError, + match=r"Apps without main windows are not supported on .*", + ): + app.main_window = toga.App.BACKGROUND diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index 103eeb6039..3c88986715 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -1,6 +1,7 @@ import asyncio import sys +import toga from textual.app import App as TextualApp from toga.command import Command from toga.handlers import simple_handler @@ -19,6 +20,9 @@ def on_mount(self) -> None: class App: + # Textual apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -61,7 +65,12 @@ def set_icon(self, icon): pass def set_main_window(self, window): - self.native.push_screen(self.interface.main_window.id) + if window is None: + raise RuntimeError("Session-based apps are not supported on Textual") + elif window == toga.App.BACKGROUND: + raise RuntimeError("Background apps are not supported on Textual") + else: + self.native.push_screen(self.interface.main_window.id) ###################################################################### # App resources diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index 3cd9e8795e..2e29edbbdb 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -1,3 +1,4 @@ +import toga from toga.app import overridden from toga.command import Command, Group from toga.handlers import simple_handler @@ -7,13 +8,16 @@ class App: + # Web apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self def create(self): - # self.resource_path = os.path.dirname(os.path.dirname(NSBundle.mainBundle.bundlePath)) self.native = js.document.getElementById("app-placeholder") + # self.resource_path = ??? # Call user code to populate the main window self.interface._startup() @@ -68,7 +72,10 @@ def set_icon(self, icon): pass def set_main_window(self, window): - pass + if window is None: + raise RuntimeError("Session-based apps are not supported on Web") + elif window == toga.App.BACKGROUND: + raise RuntimeError("Background apps are not supported on Web") ###################################################################### # App resources diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index 2b80157cec..d82ff2afd0 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -10,7 +10,7 @@ from System.Net import SecurityProtocolType, ServicePointManager from System.Windows.Threading import Dispatcher -from toga import Key +import toga from toga.app import overridden from toga.command import Command, Group from toga.handlers import simple_handler @@ -55,6 +55,9 @@ def print_stack_trace(stack_trace_line): # pragma: no cover class App: + # Winforms apps exit when the last window is closed + CLOSE_ON_LAST_WINDOW = True + def __init__(self, interface): self.interface = interface self.interface._impl = self @@ -126,8 +129,6 @@ def create(self): # Call user code to populate the main window self.interface._startup() - self.interface.main_window._impl.set_app(self) - ###################################################################### # Commands and menus ###################################################################### @@ -141,7 +142,7 @@ def create_app_commands(self): Command( self.interface.on_exit, "Exit", - shortcut=Key.MOD_1 + "q", + shortcut=toga.Key.MOD_1 + "q", group=Group.FILE, section=sys.maxsize, id=Command.EXIT, @@ -231,7 +232,7 @@ def set_icon(self, icon): window._impl.native.Icon = icon._impl.native def set_main_window(self, window): - self.app_context.MainForm = window._impl.native + pass ###################################################################### # App resources @@ -317,7 +318,7 @@ def create_app_commands(self): Command( lambda w: self.open_file, text="Open...", - shortcut=Key.MOD_1 + "o", + shortcut=toga.Key.MOD_1 + "o", group=Group.FILE, section=0, ), diff --git a/winforms/tests_backend/app.py b/winforms/tests_backend/app.py index 491ea6a74a..520421a24b 100644 --- a/winforms/tests_backend/app.py +++ b/winforms/tests_backend/app.py @@ -201,3 +201,7 @@ def activate_menu_minimize(self): def keystroke(self, combination): return winforms_to_toga_key(toga_to_winforms_key(combination)) + + async def restore_standard_app(self): + # No special handling needed to restore standard app. + await self.redraw("Restore to standard app")