diff --git a/android/src/toga_android/app.py b/android/src/toga_android/app.py index 289f976547..fa0ac8cd41 100644 --- a/android/src/toga_android/app.py +++ b/android/src/toga_android/app.py @@ -15,11 +15,6 @@ from .libs import events from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - _is_main_window = True class TogaApp(dynamic_proxy(IPythonApp)): diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index 8b14f6ba3f..3c8f522bc5 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow +from .app import App from .command import Command from .fonts import Font from .hardware.camera import Camera @@ -31,7 +31,7 @@ from .widgets.textinput import TextInput from .widgets.timeinput import TimeInput from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -41,7 +41,6 @@ def not_implemented(feature): __all__ = [ "App", "Command", - "MainWindow", "not_implemented", # Resources "dialogs", @@ -78,6 +77,8 @@ def not_implemented(feature): "TimeInput", # "Tree", "WebView", + # Windows + "MainWindow", "Window", ] diff --git a/android/src/toga_android/window.py b/android/src/toga_android/window.py index f4621a3ddb..b632f27acc 100644 --- a/android/src/toga_android/window.py +++ b/android/src/toga_android/window.py @@ -58,7 +58,9 @@ def set_title(self, title): # Window lifecycle ###################################################################### - def close(self): + def close(self): # pragma: no cover + # An Android app only ever contains a main window, and that window *can't* be + # closed, so the platform-specific close handling is never triggered. pass def create_toolbar(self): @@ -157,3 +159,7 @@ def get_image_data(self): stream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream) return bytes(stream.toByteArray()) + + +class MainWindow(Window): + _is_main_window = True diff --git a/changes/2643.bugfix.rst b/changes/2643.bugfix.rst new file mode 100644 index 0000000000..5c57adaa87 --- /dev/null +++ b/changes/2643.bugfix.rst @@ -0,0 +1 @@ +Programmatically invoking ``close()`` on the main window will now trigger ``on_exit`` handling. Previously ``on_exit`` handling would only be triggered if the close was initiated by a user action. diff --git a/changes/2643.feature.rst b/changes/2643.feature.rst new file mode 100644 index 0000000000..40c242a278 --- /dev/null +++ b/changes/2643.feature.rst @@ -0,0 +1 @@ +A MainWindow can now have an ``on_close`` handler. If a request is made to close the main window, the ``on_close`` handler will be evaluated; app exit handling will only be processed if the close handler allows the close to continue. diff --git a/cocoa/src/toga_cocoa/app.py b/cocoa/src/toga_cocoa/app.py index f7624ae1fa..e07d46714b 100644 --- a/cocoa/src/toga_cocoa/app.py +++ b/cocoa/src/toga_cocoa/app.py @@ -5,6 +5,14 @@ from pathlib import Path from urllib.parse import unquote, urlparse +from rubicon.objc import ( + SEL, + NSMutableArray, + NSMutableDictionary, + NSObject, + objc_method, + objc_property, +) from rubicon.objc.eventloop import CocoaLifecycle, EventLoopPolicy import toga @@ -15,7 +23,6 @@ from .keys import cocoa_key from .libs import ( NSURL, - SEL, NSAboutPanelOptionApplicationIcon, NSAboutPanelOptionApplicationName, NSAboutPanelOptionApplicationVersion, @@ -28,29 +35,12 @@ NSDocumentController, NSMenu, NSMenuItem, - NSMutableArray, - NSMutableDictionary, NSNumber, - NSObject, NSOpenPanel, NSScreen, NSString, - objc_method, - objc_property, ) from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def cocoa_windowShouldClose(self): - # Main Window close is a proxy for "Exit app". - # Defer all handling to the app's on_exit handler. - # As a result of calling that method, the app will either - # exit, or the user will cancel the exit; in which case - # the main window shouldn't close, either. - self.interface.app.on_exit() - return False class AppDelegate(NSObject): @@ -118,8 +108,6 @@ def validateMenuItem_(self, sender) -> bool: class App: - _MAIN_WINDOW_CLASS = MainWindow - def __init__(self, interface): self.interface = interface self.interface._impl = self diff --git a/cocoa/src/toga_cocoa/factory.py b/cocoa/src/toga_cocoa/factory.py index 981a384dc7..6a130a68e4 100644 --- a/cocoa/src/toga_cocoa/factory.py +++ b/cocoa/src/toga_cocoa/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App, DocumentApp from .command import Command from .documents import Document from .fonts import Font @@ -35,7 +35,7 @@ from .widgets.textinput import TextInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -46,7 +46,6 @@ def not_implemented(feature): "not_implemented", "App", "DocumentApp", - "MainWindow", "Command", "Document", # Resources @@ -82,6 +81,8 @@ def not_implemented(feature): "TextInput", "Tree", "WebView", + # Windows, + "MainWindow", "Window", ] diff --git a/cocoa/src/toga_cocoa/hardware/camera.py b/cocoa/src/toga_cocoa/hardware/camera.py index b47f67781c..7657180cc8 100644 --- a/cocoa/src/toga_cocoa/hardware/camera.py +++ b/cocoa/src/toga_cocoa/hardware/camera.py @@ -214,6 +214,8 @@ def change_camera(self, widget=None, **kwargs): self._update_flash_mode() def close_window(self, widget, **kwargs): + # If the user actually takes a photo, the window will be programmatically closed. + # This handler is only triggered if the user manually closes the window. # Stop the camera session self.camera_session.stopRunning() diff --git a/cocoa/src/toga_cocoa/window.py b/cocoa/src/toga_cocoa/window.py index 16442cafbe..81083fe652 100644 --- a/cocoa/src/toga_cocoa/window.py +++ b/cocoa/src/toga_cocoa/window.py @@ -1,25 +1,27 @@ -from rubicon.objc import CGSize +from rubicon.objc import ( + SEL, + CGSize, + NSMakeRect, + NSPoint, + NSSize, + objc_method, + objc_property, +) from toga.command import Command, Separator from toga.types import Position, Size from toga.window import _initial_position from toga_cocoa.container import Container from toga_cocoa.libs import ( - SEL, NSBackingStoreBuffered, NSImage, - NSMakeRect, NSMutableArray, - NSPoint, NSScreen, - NSSize, NSToolbar, NSToolbarItem, NSWindow, NSWindowStyleMask, core_graphics, - objc_method, - objc_property, ) from .screens import Screen as ScreenImpl @@ -35,7 +37,11 @@ class TogaWindow(NSWindow): @objc_method def windowShouldClose_(self, notification) -> bool: - return self.impl.cocoa_windowShouldClose() + # The on_close handler has a cleanup method that will enforce + # the close if the on_close handler requests it; this initial + # "should close" request always returns False. + self.interface.on_close() + return False @objc_method def windowDidResize_(self, notification) -> None: @@ -181,17 +187,6 @@ def __del__(self): self.purge_toolbar() self.native.release() - ###################################################################### - # Native event handlers - ###################################################################### - - def cocoa_windowShouldClose(self): - # The on_close handler has a cleanup method that will enforce - # the close if the on_close handler requests it; this initial - # "should close" request can always return False. - self.interface.on_close() - return False - ###################################################################### # Window properties ###################################################################### @@ -367,3 +362,7 @@ def get_image_data(self): ) ns_image = NSImage.alloc().initWithCGImage(cg_image, size=target_size) return ns_image + + +class MainWindow(Window): + pass diff --git a/cocoa/tests_backend/hardware/camera.py b/cocoa/tests_backend/hardware/camera.py index 052cbd39e0..8ab0587554 100644 --- a/cocoa/tests_backend/hardware/camera.py +++ b/cocoa/tests_backend/hardware/camera.py @@ -117,7 +117,7 @@ def _removeInput(input): def cleanup(self): # Ensure there are no open camrea preview windows at the end of a test. for window in self.app.camera._impl.preview_windows: - window.cocoa_windowShouldClose() + window.interface.close() def known_cameras(self): return { @@ -203,8 +203,8 @@ async def press_shutter_button(self, photo): async def cancel_photo(self, photo): window = self.app.camera._impl.preview_windows[0] - # Close the camera window. - window._impl.cocoa_windowShouldClose() + # Trigger a user close of the camera window + window.on_close() await self.redraw("Photo cancelled") # The window has been closed and the session ended diff --git a/core/src/toga/__init__.py b/core/src/toga/__init__.py index 28ee63b9e9..e9602fe54c 100644 --- a/core/src/toga/__init__.py +++ b/core/src/toga/__init__.py @@ -3,7 +3,7 @@ import warnings from pathlib import Path -from .app import App, DocumentApp, DocumentMainWindow, MainWindow +from .app import App, DocumentApp # Resources from .colors import hsl, hsla, rgb, rgba @@ -44,7 +44,7 @@ from .widgets.timeinput import TimeInput, TimePicker from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import DocumentMainWindow, MainWindow, Window class NotImplementedWarning(RuntimeWarning): @@ -62,8 +62,6 @@ def warn(cls, platform: str, feature: str) -> None: # Applications "App", "DocumentApp", - "MainWindow", - "DocumentMainWindow", # Commands "Command", "Group", @@ -112,6 +110,9 @@ def warn(cls, platform: str, feature: str) -> None: "Tree", "WebView", "Widget", + # Windows + "DocumentMainWindow", + "MainWindow", "Window", # Deprecated widget names "DatePicker", diff --git a/core/src/toga/app.py b/core/src/toga/app.py index b86ad5c139..8534272190 100644 --- a/core/src/toga/app.py +++ b/core/src/toga/app.py @@ -10,11 +10,9 @@ from email.message import Message from pathlib import Path from typing import TYPE_CHECKING, Any, MutableSet, Protocol -from warnings import warn from weakref import WeakValueDictionary from toga.command import CommandSet -from toga.documents import Document from toga.handlers import wrapped_handler from toga.hardware.camera import Camera from toga.hardware.location import Location @@ -22,13 +20,12 @@ from toga.paths import Paths from toga.platform import get_platform_factory from toga.screens import Screen -from toga.types import Position, Size from toga.widgets.base import Widget -from toga.window import OnCloseHandler, Window +from toga.window import MainWindow, Window if TYPE_CHECKING: + from toga.documents import Document from toga.icons import IconContentT - from toga.types import PositionT, SizeT # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) @@ -104,7 +101,7 @@ def discard(self, window: Window) -> None: def __iadd__(self, window: Window) -> WindowSet: # The standard set type does not have a += operator. - warn( + warnings.warn( "Windows are automatically associated with the app; += is not required", DeprecationWarning, stacklevel=2, @@ -114,7 +111,7 @@ def __iadd__(self, window: Window) -> WindowSet: def __isub__(self, other: Window) -> WindowSet: # The standard set type does have a -= operator, but it takes sets rather than # individual items. - warn( + warnings.warn( "Windows are automatically removed from the app; -= is not required", DeprecationWarning, stacklevel=2, @@ -186,120 +183,6 @@ def _remove(self, id: str) -> None: del self._registry[id] -class MainWindow(Window): - _WINDOW_CLASS = "MainWindow" - - def __init__( - self, - id: str | None = None, - title: str | None = None, - position: PositionT | None = None, - size: SizeT = Size(640, 480), - resizable: bool = True, - minimizable: bool = True, - content: Widget | None = None, - resizeable: None = None, # DEPRECATED - closeable: None = None, # DEPRECATED - ): - """Create a new main window. - - :param id: A unique identifier for the window. If not provided, one will be - automatically generated. - :param title: Title for the window. Defaults to the formal name of the app. - :param position: Position of the window, as a :any:`toga.Position` or tuple of - ``(x, y)`` coordinates, in :ref:`CSS pixels `. - :param size: Size of the window, as a :any:`toga.Size` or tuple of ``(width, - height)``, in :ref:`CSS pixels `. - :param resizable: Can the window be resized by the user? - :param minimizable: Can the window be minimized by the user? - :param content: The initial content for the window. - :param resizeable: **DEPRECATED** - Use ``resizable``. - :param closeable: **DEPRECATED** - Use ``closable``. - """ - super().__init__( - id=id, - title=title, - position=position, - size=size, - resizable=resizable, - closable=True, - minimizable=minimizable, - content=content, - # Deprecated arguments - resizeable=resizeable, - closeable=closeable, - ) - - @property - def _default_title(self) -> str: - return App.app.formal_name - - @property - def on_close(self) -> None: - """The handler to invoke before the window is closed in response to a user - action. - - Always returns ``None``. Main windows should use :meth:`toga.App.on_exit`, - rather than ``on_close``. - - :raises ValueError: if an attempt is made to set the ``on_close`` handler. - """ - return None - - @on_close.setter - def on_close(self, handler: OnCloseHandler | None) -> None: - if handler: - raise ValueError( - "Cannot set on_close handler for the main window. " - "Use the app on_exit handler instead." - ) - - -class DocumentMainWindow(Window): - def __init__( - self, - doc: Document, - id: str | None = None, - title: str | None = None, - position: PositionT = Position(100, 100), - size: SizeT = Size(640, 480), - resizable: bool = True, - minimizable: bool = True, - ): - """Create a new document Main Window. - - This installs a default on_close handler that honors platform-specific document - closing behavior. If you want to control whether a document is allowed to close - (e.g., due to having unsaved change), override - :meth:`toga.Document.can_close()`, rather than implementing an on_close handler. - - :param doc: The document being managed by this window - :param id: The ID of the window. - :param title: Title for the window. Defaults to the formal name of the app. - :param position: Position of the window, as a :any:`toga.Position` or tuple of - ``(x, y)`` coordinates. - :param size: Size of the window, as a :any:`toga.Size` or tuple of - ``(width, height)``, in pixels. - :param resizable: Can the window be manually resized by the user? - :param minimizable: Can the window be minimized by the user? - """ - self.doc = doc - super().__init__( - id=id, - title=title, - position=position, - size=size, - resizable=resizable, - closable=True, - minimizable=minimizable, - on_close=doc.handle_close, - ) - - @property - def _default_title(self) -> str: - return self.doc.path.name - - def overridable(method): """Decorate the method as being user-overridable""" method._overridden = True @@ -381,7 +264,7 @@ def __init__( # 2023-10: Backwards compatibility ###################################################################### if id is not None: - warn( + warnings.warn( "App.id is deprecated and will be ignored. Use app_id instead", DeprecationWarning, stacklevel=2, @@ -569,7 +452,7 @@ def icon(self, icon_or_name: IconContentT) -> None: @property def id(self) -> str: """**DEPRECATED** – Use :any:`app_id`.""" - warn( + warnings.warn( "App.id is deprecated. Use app_id instead", DeprecationWarning, stacklevel=2 ) return self._app_id @@ -868,7 +751,7 @@ def cleanup(app: App, should_exit: bool) -> None: @property def name(self) -> str: """**DEPRECATED** – Use :any:`formal_name`.""" - warn( + warnings.warn( "App.name is deprecated. Use formal_name instead", DeprecationWarning, stacklevel=2, diff --git a/core/src/toga/window.py b/core/src/toga/window.py index 2995eadc9f..238c9f2db0 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -13,6 +13,7 @@ overload, ) +import toga from toga.command import CommandSet from toga.handlers import AsyncResult, wrapped_handler from toga.images import Image @@ -21,6 +22,7 @@ if TYPE_CHECKING: from toga.app import App + from toga.documents import Document from toga.images import ImageT from toga.screens import Screen from toga.types import PositionT, SizeT @@ -290,19 +292,25 @@ def title(self, title: str) -> None: def close(self) -> None: """Close the window. - This *does not* invoke the ``on_close`` handler; the window will be immediately - and unconditionally closed. + This *does not* invoke the ``on_close`` handler. If the window being closed + is the app's main window, it will trigger ``on_exit`` handling; otherwise, the + window will be immediately and unconditionally closed. Once a window has been closed, it *cannot* be reused. The behavior of any method or property on a :class:`~toga.Window` instance after it has been closed is undefined, except for :attr:`closed` which can be used to check if the window was closed. """ - if self.content: - self.content.window = None - self.app.windows.discard(self) - self._impl.close() - self._closed = 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 @property def closed(self) -> bool: @@ -939,3 +947,102 @@ def closeable(self) -> bool: ###################################################################### # End Backwards compatibility ###################################################################### + + +class MainWindow(Window): + _WINDOW_CLASS = "MainWindow" + + def __init__( + self, + id: str | None = None, + title: str | None = None, + position: PositionT | None = None, + size: SizeT = Size(640, 480), + resizable: bool = True, + minimizable: bool = True, + on_close: OnCloseHandler | None = None, + content: Widget | None = None, + resizeable: None = None, # DEPRECATED + closeable: None = None, # DEPRECATED + ): + """Create a new main window. + + :param id: A unique identifier for the window. If not provided, one will be + automatically generated. + :param title: Title for the window. Defaults to the formal name of the app. + :param position: Position of the window, as a :any:`toga.Position` or tuple of + ``(x, y)`` coordinates, in :ref:`CSS pixels `. + :param size: Size of the window, as a :any:`toga.Size` or tuple of ``(width, + height)``, in :ref:`CSS pixels `. + :param resizable: Can the window be resized by the user? + :param minimizable: Can the window be minimized by the user? + :param content: The initial content for the window. + :param on_close: The initial :any:`on_close` handler. + :param resizeable: **DEPRECATED** - Use ``resizable``. + :param closeable: **DEPRECATED** - Use ``closable``. + """ + super().__init__( + id=id, + title=title, + position=position, + size=size, + resizable=resizable, + closable=True, + minimizable=minimizable, + content=content, + on_close=on_close, + # Deprecated arguments + resizeable=resizeable, + closeable=closeable, + ) + + @property + def _default_title(self) -> str: + return toga.App.app.formal_name + + +class DocumentMainWindow(Window): + def __init__( + self, + doc: Document, + id: str | None = None, + title: str | None = None, + position: PositionT = Position(100, 100), + size: SizeT = Size(640, 480), + resizable: bool = True, + minimizable: bool = True, + on_close: OnCloseHandler | None = None, + ): + """Create a new document Main Window. + + This installs a default on_close handler that honors platform-specific document + closing behavior. If you want to control whether a document is allowed to close + (e.g., due to having unsaved change), override + :meth:`toga.Document.can_close()`, rather than implementing an on_close handler. + + :param doc: The document being managed by this window + :param id: The ID of the window. + :param title: Title for the window. Defaults to the formal name of the app. + :param position: Position of the window, as a :any:`toga.Position` or tuple of + ``(x, y)`` coordinates. + :param size: Size of the window, as a :any:`toga.Size` or tuple of + ``(width, height)``, in pixels. + :param resizable: Can the window be manually resized by the user? + :param minimizable: Can the window be minimized by the user? + :param on_close: The initial :any:`on_close` handler. + """ + self.doc = doc + super().__init__( + id=id, + title=title, + position=position, + size=size, + resizable=resizable, + closable=True, + minimizable=minimizable, + on_close=doc.handle_close if on_close is None else on_close, + ) + + @property + def _default_title(self) -> str: + return self.doc.path.name diff --git a/core/tests/app/test_mainwindow.py b/core/tests/app/test_mainwindow.py deleted file mode 100644 index 7112ba1942..0000000000 --- a/core/tests/app/test_mainwindow.py +++ /dev/null @@ -1,42 +0,0 @@ -from unittest.mock import Mock - -import pytest - -import toga -from toga_dummy.utils import assert_action_performed - - -def test_create(app): - """A MainWindow can be created with minimal arguments.""" - window = toga.MainWindow() - - assert window.app == app - assert window.content is None - - assert window._impl.interface == window - assert_action_performed(window, "create Window") - - # We can't know what the ID is, but it must be a string. - assert isinstance(window.id, str) - # Window title is the app title. - assert window.title == "Test App" - # The app has created a main window, so this will be the second window. - assert window.position == (150, 150) - assert window.size == (640, 480) - assert window.resizable - assert window.closable - assert window.minimizable - assert len(window.toolbar) == 0 - # No on-close handler - assert window.on_close is None - - -def test_no_close(): - """An on_close handler cannot be set on MainWindow.""" - window = toga.MainWindow() - - with pytest.raises( - ValueError, - match=r"Cannot set on_close handler for the main window. Use the app on_exit handler instead.", - ): - window.on_close = Mock() diff --git a/core/tests/window/test_mainwindow.py b/core/tests/window/test_mainwindow.py new file mode 100644 index 0000000000..ce76c4727c --- /dev/null +++ b/core/tests/window/test_mainwindow.py @@ -0,0 +1,65 @@ +from unittest.mock import Mock + +import toga +from toga_dummy.utils import assert_action_performed + + +def test_create(app): + """A MainWindow can be created with minimal arguments.""" + window = toga.MainWindow() + + assert window.app == app + assert window.content is None + + assert window._impl.interface == window + assert_action_performed(window, "create MainWindow") + + # We can't know what the ID is, but it must be a string. + assert isinstance(window.id, str) + # Window title is the app title. + assert window.title == "Test App" + # The app has created a main window, so this will be the second window. + assert window.position == (150, 150) + assert window.size == (640, 480) + assert window.resizable + assert window.closable + assert window.minimizable + assert len(window.toolbar) == 0 + # No on-close handler + assert window.on_close._raw is None + + +def test_create_explicit(app): + """Explicit arguments at construction are stored.""" + on_close_handler = Mock() + window_content = toga.Box() + + window = toga.MainWindow( + id="my-window", + title="My Window", + position=toga.Position(10, 20), + size=toga.Position(200, 300), + resizable=False, + minimizable=False, + content=window_content, + on_close=on_close_handler, + ) + + assert window.app == app + assert window.content == window_content + + window_content.window == window + window_content.app == app + + assert window._impl.interface == window + assert_action_performed(window, "create MainWindow") + + assert window.id == "my-window" + assert window.title == "My Window" + assert window.position == toga.Position(10, 20) + assert window.size == toga.Size(200, 300) + assert not window.resizable + assert window.closable + assert not window.minimizable + assert len(window.toolbar) == 0 + assert window.on_close._raw == on_close_handler diff --git a/core/tests/window/test_window.py b/core/tests/window/test_window.py index 784af9562d..27eca0698d 100644 --- a/core/tests/window/test_window.py +++ b/core/tests/window/test_window.py @@ -42,6 +42,7 @@ def test_window_created(app): def test_window_created_explicit(app): """Explicit arguments at construction are stored.""" on_close_handler = Mock() + window_content = toga.Box() window = toga.Window( id="my-window", @@ -51,11 +52,15 @@ def test_window_created_explicit(app): resizable=False, closable=False, minimizable=False, + content=window_content, on_close=on_close_handler, ) assert window.app == app - assert window.content is None + assert window.content == window_content + + window_content.window == window + window_content.app == app assert window._impl.interface == window assert_action_performed(window, "create Window") @@ -364,6 +369,36 @@ def test_close_direct(window, app): on_close_handler.assert_not_called() +def test_close_direct_main_window(app): + """If the main window is closed directly, it triggers app exit logic.""" + window = app.main_window + + on_close_handler = Mock(return_value=True) + window.on_close = on_close_handler + + on_exit_handler = Mock(return_value=True) + app.on_exit = on_exit_handler + + window.show() + assert window.app == app + assert window in app.windows + + # Close the window directly + window.close() + + # Window has *not* been closed. + assert not window.closed + assert window.app == app + assert window in app.windows + assert_action_not_performed(window, "close") + + # The close handler has *not* been invoked, but + # the exit handler *has*. + on_close_handler.assert_not_called() + on_exit_handler.assert_called_once_with(app) + assert_action_performed(app, "exit") + + def test_close_no_handler(window, app): """A window without a close handler can be closed.""" window.show() diff --git a/docs/reference/api/mainwindow.rst b/docs/reference/api/mainwindow.rst index 7dc17936d7..d47f5147bd 100644 --- a/docs/reference/api/mainwindow.rst +++ b/docs/reference/api/mainwindow.rst @@ -66,11 +66,6 @@ when the main window is closed, the application exits. self.toga.App.main_window = main_window main_window.show() -As the main window is closely bound to the App, a main window *cannot* define an -``on_close`` handler. Instead, if you want to prevent the main window from exiting, you -should use an ``on_exit`` handler on the :class:`toga.App` that the main window is -associated with. - Reference --------- diff --git a/dummy/src/toga_dummy/app.py b/dummy/src/toga_dummy/app.py index 02a03d18f9..916d463848 100644 --- a/dummy/src/toga_dummy/app.py +++ b/dummy/src/toga_dummy/app.py @@ -8,11 +8,6 @@ from .screens import Screen as ScreenImpl from .utils import LoggedObject -from .window import Window - - -class MainWindow(Window): - pass class App(LoggedObject): diff --git a/dummy/src/toga_dummy/factory.py b/dummy/src/toga_dummy/factory.py index 852241c3bb..8eea5ef894 100644 --- a/dummy/src/toga_dummy/factory.py +++ b/dummy/src/toga_dummy/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App, DocumentApp from .command import Command from .documents import Document from .fonts import Font @@ -36,7 +36,7 @@ from .widgets.timeinput import TimeInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -47,7 +47,6 @@ def not_implemented(feature): "not_implemented", "App", "DocumentApp", - "MainWindow", "Command", "Document", "Font", @@ -84,6 +83,8 @@ def not_implemented(feature): "TimeInput", "Tree", "WebView", + # Windows + "MainWindow", "Window", # Widget is also required for testing purposes # Real backends shouldn't expose Widget. diff --git a/dummy/src/toga_dummy/window.py b/dummy/src/toga_dummy/window.py index bebf0332b5..dfd3b8cee9 100644 --- a/dummy/src/toga_dummy/window.py +++ b/dummy/src/toga_dummy/window.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path import toga_dummy @@ -46,7 +47,7 @@ def refreshed(self): class Window(LoggedObject): def __init__(self, interface, title, position, size): super().__init__() - self._action("create Window") + self._action(f"create {self.__class__.__name__}") self.interface = interface self.container = Container() @@ -54,19 +55,60 @@ def __init__(self, interface, title, position, size): self.set_position(position if position is not None else _initial_position()) self.set_size(size) + ###################################################################### + # Window properties + ###################################################################### + + def get_title(self): + return self._get_value("title") + + def set_title(self, title): + self._set_value("title", title) + + ###################################################################### + # Window lifecycle + ###################################################################### + + def close(self): + self._action("close") + self._set_value("visible", False) + def create_toolbar(self): self._action("create toolbar") + def set_app(self, app): + self._set_value("app", app) + + def show(self): + self._action("show") + self._set_value("visible", True) + + ###################################################################### + # Window content and resources + ###################################################################### + def set_content(self, widget): self.container.content = widget self._action("set content", widget=widget) self._set_value("content", widget) - def get_title(self): - return self._get_value("title") + ###################################################################### + # Window size + ###################################################################### - def set_title(self, title): - self._set_value("title", title) + def get_size(self) -> Size: + return self._get_value("size", Size(640, 480)) + + def set_size(self, size): + self._set_value("size", size) + + ###################################################################### + # Window position + ###################################################################### + + def get_current_screen(self): + # `window.screen` will return `Secondary Screen` + return ScreenImpl(native=("Secondary Screen", (-1366, -768), (1366, 768))) def get_position(self): return self._get_value("position") @@ -74,41 +116,42 @@ def get_position(self): def set_position(self, position): self._set_value("position", position) - def get_size(self) -> Size: - return self._get_value("size", Size(640, 480)) - - def set_size(self, size): - self._set_value("size", size) + ###################################################################### + # Window visibility + ###################################################################### - def set_app(self, app): - self._set_value("app", app) - - def show(self): - self._action("show") - self._set_value("visible", True) + def get_visible(self): + return self._get_value("visible") def hide(self): self._action("hide") self._set_value("visible", False) - def get_visible(self): - return self._get_value("visible") + ###################################################################### + # Window state + ###################################################################### - def close(self): - self._action("close") - self._set_value("visible", False) + def set_full_screen(self, is_full_screen): + self._action("set full screen", full_screen=is_full_screen) + + ###################################################################### + # Window capabilities + ###################################################################### def get_image_data(self): self._action("get image data") path = Path(toga_dummy.__file__).parent / "resources/screenshot.png" return path.read_bytes() - def set_full_screen(self, is_full_screen): - self._action("set full screen", full_screen=is_full_screen) + ###################################################################### + # Simulation interface + ###################################################################### def simulate_close(self): - self.interface.on_close() + result = self.interface.on_close() + if asyncio.iscoroutine(result): + self.interface.app.loop.run_until_complete(result) - def get_current_screen(self): - # `window.screen` will return `Secondary Screen` - return ScreenImpl(native=("Secondary Screen", (-1366, -768), (1366, 768))) + +class MainWindow(Window): + pass diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index fcf7e9868f..11aad1ca9c 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -14,24 +14,6 @@ from .keys import gtk_accel from .libs import TOGA_DEFAULT_STYLES, Gdk, Gio, GLib, Gtk from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def create(self): - self.native = Gtk.ApplicationWindow() - self.native.set_role("MainWindow") - - def gtk_delete_event(self, *args): - # Return value of the GTK on_close handler indicates - # whether the event has been fully handled. Returning - # False indicates the event handling is *not* complete, - # so further event processing (including actually - # closing the window) should be performed; so - # "should_exit == True" must be converted to a return - # value of False. - self.interface.app.on_exit() - return True class App: diff --git a/gtk/src/toga_gtk/factory.py b/gtk/src/toga_gtk/factory.py index a0d48f0af1..0d75642e8a 100644 --- a/gtk/src/toga_gtk/factory.py +++ b/gtk/src/toga_gtk/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App, DocumentApp from .command import Command from .documents import Document from .fonts import Font @@ -31,7 +31,7 @@ from .widgets.textinput import TextInput from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -42,7 +42,6 @@ def not_implemented(feature): "not_implemented", "App", "DocumentApp", - "MainWindow", "Command", "Document", # Resources @@ -75,6 +74,8 @@ def not_implemented(feature): "TextInput", "Tree", "WebView", + # Windows + "MainWindow", "Window", ] diff --git a/gtk/src/toga_gtk/window.py b/gtk/src/toga_gtk/window.py index b35569a135..d1eefb19e6 100644 --- a/gtk/src/toga_gtk/window.py +++ b/gtk/src/toga_gtk/window.py @@ -19,14 +19,15 @@ def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self - self._is_closing = False - self.layout = None self.create() self.native._impl = self - self.native.connect("delete-event", self.gtk_delete_event) + self._delete_handler = self.native.connect( + "delete-event", + self.gtk_delete_event, + ) self.native.set_default_size(size[0], size[1]) @@ -67,17 +68,12 @@ def create(self): ###################################################################### def gtk_delete_event(self, widget, data): - if self._is_closing: - should_close = True - else: - should_close = self.interface.on_close() - - # Return value of the GTK on_close handler indicates - # whether the event has been fully handled. Returning - # False indicates the event handling is *not* complete, - # so further event processing (including actually - # closing the window) should be performed. - return not should_close + # Return value of the GTK on_close handler indicates whether the event has been + # fully handled. Returning True indicates the event has been handled, so further + # handling (including actually closing the window) shouldn't be performed. This + # handler must be deleted to allow the window to actually close. + self.interface.on_close() + return True ###################################################################### # Window properties @@ -94,7 +90,8 @@ def set_title(self, title): ###################################################################### def close(self): - self._is_closing = True + # Disconnect the delete handler so the close will complete + self.native.disconnect(self._delete_handler) self.native.close() def create_toolbar(self): @@ -243,3 +240,9 @@ def get_image_data(self): else: # pragma: nocover # This shouldn't ever happen, and it's difficult to manufacture in test conditions raise ValueError(f"Unable to generate screenshot of {self}") + + +class MainWindow(Window): + def create(self): + self.native = Gtk.ApplicationWindow() + self.native.set_role("MainWindow") diff --git a/gtk/tests_backend/window.py b/gtk/tests_backend/window.py index ed115e2577..28b1ffaf42 100644 --- a/gtk/tests_backend/window.py +++ b/gtk/tests_backend/window.py @@ -29,7 +29,8 @@ async def wait_for_window(self, message, minimize=False, full_screen=False): def close(self): if self.is_closable: - self.native.close() + # Trigger the OS-level window close event. + self.native.emit("delete-event", None) @property def content_size(self): diff --git a/iOS/src/toga_iOS/app.py b/iOS/src/toga_iOS/app.py index 7988968e96..686c4b9bdb 100644 --- a/iOS/src/toga_iOS/app.py +++ b/iOS/src/toga_iOS/app.py @@ -4,15 +4,10 @@ from rubicon.objc.eventloop import EventLoopPolicy, iOSLifecycle from toga_iOS.libs import UIResponder, UIScreen, av_foundation -from toga_iOS.window import Window from .screens import Screen as ScreenImpl -class MainWindow(Window): - _is_main_window = True - - class PythonAppDelegate(UIResponder): @objc_method def applicationDidBecomeActive_(self, application) -> None: diff --git a/iOS/src/toga_iOS/factory.py b/iOS/src/toga_iOS/factory.py index 2c7088d206..e9e8de1190 100644 --- a/iOS/src/toga_iOS/factory.py +++ b/iOS/src/toga_iOS/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow +from .app import App from .colors import native_color from .command import Command from .fonts import Font @@ -36,7 +36,7 @@ # from .widgets.tree import Tree from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -46,7 +46,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "MainWindow", "Command", # Resources "native_color", # colors @@ -80,6 +79,8 @@ def not_implemented(feature): "TextInput", # 'Tree', "WebView", + # Windows + "MainWindow", "Window", ] diff --git a/iOS/src/toga_iOS/window.py b/iOS/src/toga_iOS/window.py index a2ae85ef90..3fdab86fb1 100644 --- a/iOS/src/toga_iOS/window.py +++ b/iOS/src/toga_iOS/window.py @@ -71,7 +71,9 @@ def set_title(self, title): # Window lifecycle ###################################################################### - def close(self): + def close(self): # pragma: no cover + # An iOS app only ever contains a main window, and that window *can't* be + # closed, so the platform-specific close handling is never triggered. pass def create_toolbar(self): @@ -222,3 +224,7 @@ def render(context): final_image = UIImage.imageWithCGImage(cropped_image) # Convert into PNG data. return nsdata_to_bytes(NSData(uikit.UIImagePNGRepresentation(final_image))) + + +class MainWindow(Window): + _is_main_window = True diff --git a/testbed/tests/app/test_app.py b/testbed/tests/app/test_app.py index 3823242991..6c8e85bcd1 100644 --- a/testbed/tests/app/test_app.py +++ b/testbed/tests/app/test_app.py @@ -68,30 +68,60 @@ async def test_device_rotation(app, app_probe): #################################################################################### async def test_exit_on_close_main_window( - monkeypatch, app, + main_window, main_window_probe, mock_app_exit, ): """An app can be exited by closing the main window""" - # Rebind the exit command to the on_exit handler. + # 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 - monkeypatch.setattr(app.commands[toga.Command.EXIT], "_action", app.on_exit) - # Close the main window + # Try to close the main window; rejected by window main_window_probe.close() - await main_window_probe.redraw("Main window close requested, but rejected") + 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, and accepted") + 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) diff --git a/textual/src/toga_textual/app.py b/textual/src/toga_textual/app.py index 2745058834..222dbb6d14 100644 --- a/textual/src/toga_textual/app.py +++ b/textual/src/toga_textual/app.py @@ -3,12 +3,6 @@ from textual.app import App as TextualApp from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def textual_close(self): - self.interface.app.on_exit() class TogaApp(TextualApp): diff --git a/textual/src/toga_textual/factory.py b/textual/src/toga_textual/factory.py index 06217c2350..dd7ec3f442 100644 --- a/textual/src/toga_textual/factory.py +++ b/textual/src/toga_textual/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, DocumentApp, MainWindow +from .app import App, DocumentApp # from .command import Command # from .documents import Document @@ -39,7 +39,7 @@ # from .widgets.timeinput import TimeInput # from .widgets.tree import Tree # from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -50,7 +50,6 @@ def not_implemented(feature): "not_implemented", "App", "DocumentApp", - "MainWindow", # "Command", # "Document", # "Font", @@ -83,6 +82,8 @@ def not_implemented(feature): # "TimeInput", # "Tree", # "WebView", + # Windows + "MainWindow", "Window", ] diff --git a/textual/src/toga_textual/window.py b/textual/src/toga_textual/window.py index 92955de168..39c09ea6fa 100644 --- a/textual/src/toga_textual/window.py +++ b/textual/src/toga_textual/window.py @@ -212,3 +212,7 @@ def set_full_screen(self, is_full_screen): def get_image_data(self): self.interface.factory.not_implemented("Window.get_image_data()") + + +class MainWindow(Window): + pass diff --git a/web/src/toga_web/app.py b/web/src/toga_web/app.py index 620b2171bd..291a9bb0fc 100644 --- a/web/src/toga_web/app.py +++ b/web/src/toga_web/app.py @@ -3,16 +3,10 @@ from toga.command import Command, Group, Separator from toga.handlers import simple_handler from toga_web.libs import create_element, js -from toga_web.window import Window from .screens import Screen as ScreenImpl -class MainWindow(Window): - def on_close(self, *args): - pass - - class App: def __init__(self, interface): self.interface = interface diff --git a/web/src/toga_web/factory.py b/web/src/toga_web/factory.py index bef184f1d9..bef93488e7 100644 --- a/web/src/toga_web/factory.py +++ b/web/src/toga_web/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow # DocumentApp +from .app import App from .command import Command # from .documents import Document @@ -37,7 +37,7 @@ # from .widgets.tree import Tree # from .widgets.webview import WebView -# from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -47,7 +47,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "MainWindow", # 'DocumentApp', "Command", # 'Document', @@ -80,7 +79,9 @@ def not_implemented(feature): "TextInput", # 'Tree', # 'WebView', - # 'Window', + # Windows + "MainWindow", + "Window", ] diff --git a/web/src/toga_web/window.py b/web/src/toga_web/window.py index 76ded4a7b0..45f0e02961 100644 --- a/web/src/toga_web/window.py +++ b/web/src/toga_web/window.py @@ -122,3 +122,7 @@ def set_full_screen(self, is_full_screen): def get_image_data(self): self.interface.factory.not_implemented("Window.get_image_data()") + + +class MainWindow(Window): + pass diff --git a/winforms/src/toga_winforms/app.py b/winforms/src/toga_winforms/app.py index f14789c0ec..7e86f2ad8c 100644 --- a/winforms/src/toga_winforms/app.py +++ b/winforms/src/toga_winforms/app.py @@ -20,19 +20,6 @@ from .libs.proactor import WinformsProactorEventLoop from .libs.wrapper import WeakrefCallable from .screens import Screen as ScreenImpl -from .window import Window - - -class MainWindow(Window): - def winforms_FormClosing(self, sender, event): - # Differentiate between the handling that occurs when the user - # requests the app to exit, and the actual application exiting. - if not self.interface.app._impl._is_exiting: # pragma: no branch - # If there's an event handler, process it. The decision to - # actually exit the app will be processed in the on_exit handler. - # If there's no exit handler, assume the close/exit can proceed. - self.interface.app.on_exit() - event.Cancel = True def winforms_thread_exception(sender, winforms_exc): # pragma: no cover @@ -70,19 +57,12 @@ def print_stack_trace(stack_trace_line): # pragma: no cover class App: - _MAIN_WINDOW_CLASS = MainWindow - def __init__(self, interface): self.interface = interface self.interface._impl = self - # Winforms app exit is tightly bound to the close of the MainWindow. - # The FormClosing message on MainWindow triggers the "on_exit" handler - # (which might abort the exit). However, on success, it will request the - # app (and thus the Main Window) to close, causing another close event. - # So - we have a flag that is only ever sent once a request has been - # made to exit the native app. This flag can be used to shortcut any - # window-level close handling. + # Track whether the app is exiting. This is used to stop the event loop, + # and shortcut close handling on any open windows when the app exits. self._is_exiting = False # Winforms cursor visibility is a stack; If you call hide N times, you diff --git a/winforms/src/toga_winforms/factory.py b/winforms/src/toga_winforms/factory.py index 80f097ea10..5c7e035366 100644 --- a/winforms/src/toga_winforms/factory.py +++ b/winforms/src/toga_winforms/factory.py @@ -1,7 +1,7 @@ from toga import NotImplementedWarning from . import dialogs -from .app import App, MainWindow +from .app import App from .command import Command from .fonts import Font from .icons import Icon @@ -30,7 +30,7 @@ from .widgets.textinput import TextInput from .widgets.timeinput import TimeInput from .widgets.webview import WebView -from .window import Window +from .window import MainWindow, Window def not_implemented(feature): @@ -40,7 +40,6 @@ def not_implemented(feature): __all__ = [ "not_implemented", "App", - "MainWindow", "Command", # Resources "Font", @@ -72,7 +71,9 @@ def not_implemented(feature): "TextInput", "TimeInput", "WebView", + # Windows "Window", + "MainWindow", ] diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 753f016c3a..01536127e5 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -23,15 +23,9 @@ class Window(Container, Scalable): def __init__(self, interface, title, position, size): self.interface = interface - # Winforms close handling is caught on the FormClosing handler. To allow - # for async close handling, we need to be able to abort this close event, - # call the Toga event handler, and let that decide whether to call close(). - # If it does, there will be another FormClosing event, which we need - # to ignore. The `_is_closing` flag lets us do this. - self._is_closing = False - self.native = WinForms.Form() - self.native.FormClosing += WeakrefCallable(self.winforms_FormClosing) + self._FormClosing_handler = WeakrefCallable(self.winforms_FormClosing) + self.native.FormClosing += self._FormClosing_handler super().__init__(self.native) self.init_scale(self.native) @@ -60,16 +54,22 @@ def winforms_Resize(self, sender, event): self.resize_content() def winforms_FormClosing(self, sender, event): - # If the app is exiting, or a manual close has been requested, don't get - # confirmation; just close. - if not self.interface.app._impl._is_exiting and not self._is_closing: - if not self.interface.closable: - # Window isn't closable, so any request to close should be cancelled. - event.Cancel = True - else: - # See _is_closing comment in __init__. + # If the app is exiting, do nothing; we've already approved the exit + # (and thus the window close). This branch can't be triggered in test + # conditions, so it's marked no-branch. + # + # Otherwise, handle the close request by always cancelling the event, + # and invoking `on_close()` handling. This will evaluate whether a close + # is allowed, and if it is, programmatically invoke close on the window, + # removing this handler first so that the close will complete. + # + # Winforms doesn't provide a way to disable/hide the close button, so if + # the window is non-closable, don't trigger on_close handling - just + # cancel the close event. + if not self.interface.app._impl._is_exiting: # pragma: no branch + if self.interface.closable: self.interface.on_close() - event.Cancel = True + event.Cancel = True ###################################################################### # Window properties @@ -86,7 +86,7 @@ def set_title(self, title): ###################################################################### def close(self): - self._is_closing = True + self.native.FormClosing -= self._FormClosing_handler self.native.Close() def create_toolbar(self): @@ -251,3 +251,7 @@ def get_image_data(self): stream = MemoryStream() bitmap.Save(stream, ImageFormat.Png) return bytes(stream.ToArray()) + + +class MainWindow(Window): + pass