diff --git a/examples/dialogs/dialogs/app.py b/examples/dialogs/dialogs/app.py index a5e598f859..06f0e266bd 100644 --- a/examples/dialogs/dialogs/app.py +++ b/examples/dialogs/dialogs/app.py @@ -104,12 +104,75 @@ def action_save_file_dialog(self, widget): except ValueError: self.label.text = "Save file dialog was canceled" + def window_close_handler(self, window): + # This handler is called before the window is closed, so there + # still are 1 more windows than the number of secondary windows + # after it is closed + # Return False if the window should stay open + + # Check to see if there has been a previous close attempt. + if window in self.close_attempts: + # If there has, update the window label and allow + # the close to proceed. The count is -2 (rather than -1) + # because *this* window hasn't been removed from + # the window list. + self.set_window_label_text(len(self.windows) - 2) + return True + else: + window.info_dialog(f'Abort {window.title}!', 'Maybe try that again...') + self.close_attempts.add(window) + return False + + def action_open_secondary_window(self, widget): + self.window_counter += 1 + window = toga.Window(title=f"New Window {self.window_counter}") + # Both self.windows.add() and self.windows += work: + self.windows += window + + self.set_window_label_text(len(self.windows) - 1) + secondary_label = toga.Label(text="You are in a secondary window!") + window.content = toga.Box( + children=[ + secondary_label + ], + style=Pack( + flex=1, + direction=COLUMN, + padding=10 + ) + ) + window.on_close = self.window_close_handler + window.show() + + def action_close_secondary_windows(self, widget): + # Close all windows that aren't the main window. + for window in list(self.windows): + if not isinstance(window, toga.MainWindow): + window.close() + + def exit_handler(self, app): + # Return True if app should close, and False if it should remain open + if self.main_window.confirm_dialog('Toga', 'Are you sure you want to quit?'): + print(f"Label text was '{self.label.text}' when you quit the app") + return True + else: + self.label.text = 'Exit canceled' + return False + + def set_window_label_text(self, num_windows): + self.window_label.text = f"{num_windows} secondary window(s) open" + def startup(self): # Set up main window self.main_window = toga.MainWindow(title=self.name) + self.on_exit = self.exit_handler # Label to show responses. self.label = toga.Label('Ready.', style=Pack(padding_top=20)) + self.window_label = toga.Label('', style=Pack(padding_top=20)) + self.window_counter = 0 + self.close_attempts = set() + self.set_window_label_text(0) # Buttons btn_style = Pack(flex=1) @@ -135,6 +198,16 @@ def startup(self): on_press=self.action_select_folder_dialog_multi, style=btn_style ) + btn_open_secondary_window = toga.Button( + 'Open Secondary Window', + on_press=self.action_open_secondary_window, + style=btn_style + ) + btn_close_secondary_window = toga.Button( + 'Close All Secondary Windows', + on_press=self.action_close_secondary_windows, + style=btn_style + ) btn_clear = toga.Button('Clear', on_press=self.do_clear, style=btn_style) @@ -151,8 +224,11 @@ def startup(self): btn_select, btn_select_multi, btn_open_multi, + btn_open_secondary_window, + btn_close_secondary_window, btn_clear, - self.label + self.label, + self.window_label ], style=Pack( flex=1, diff --git a/src/cocoa/toga_cocoa/app.py b/src/cocoa/toga_cocoa/app.py index b3e162fd00..a658ba6f29 100644 --- a/src/cocoa/toga_cocoa/app.py +++ b/src/cocoa/toga_cocoa/app.py @@ -36,16 +36,17 @@ class MainWindow(Window): - def on_close(self): + def cocoa_windowShouldClose(self): + # Main Window close is a proxy for "Exit app". + # Defer all handling to the app's exit method. + # 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.exit() + return False class AppDelegate(NSObject): - @objc_method - def applicationWillTerminate_(self, sender): - if self.interface.app.on_exit: - self.interface.app.on_exit(self.interface.app) - @objc_method def applicationDidFinishLaunching_(self, notification): self.native.activateIgnoringOtherApps(True) @@ -261,7 +262,7 @@ def show_about_dialog(self): self.native.orderFrontStandardAboutPanelWithOptions(options) def exit(self): - self.native.terminate(None) + self.native.terminate(self.native) def set_on_exit(self, value): pass diff --git a/src/cocoa/toga_cocoa/window.py b/src/cocoa/toga_cocoa/window.py index ec9be45272..82f70a551f 100644 --- a/src/cocoa/toga_cocoa/window.py +++ b/src/cocoa/toga_cocoa/window.py @@ -49,8 +49,8 @@ def height(self): class WindowDelegate(NSObject): @objc_method - def windowWillClose_(self, notification) -> None: - self.interface.on_close() + def windowShouldClose_(self, notification) -> bool: + return self.impl.cocoa_windowShouldClose() @objc_method def windowDidResize_(self, notification) -> None: @@ -246,11 +246,24 @@ def show(self): def set_full_screen(self, is_full_screen): self.interface.factory.not_implemented('Window.set_full_screen()') - def on_close(self): + def set_on_close(self, handler): pass + def cocoa_windowShouldClose(self): + if self.interface.on_close: + should_close = self.interface.on_close(self) + else: + should_close = True + + if should_close: + self.interface.app.windows -= self.interface + + return should_close + def close(self): - self.native.close() + # Calling performClose instead of close ensures that the on_close + # handlers in the delegates will be called + self.native.performClose(self.native) def info_dialog(self, title, message): return dialogs.info(self.interface, title, message) diff --git a/src/core/tests/test_app.py b/src/core/tests/test_app.py index 8af281849b..f25d84825c 100644 --- a/src/core/tests/test_app.py +++ b/src/core/tests/test_app.py @@ -88,6 +88,10 @@ def test_is_full_screen(self): self.assertFalse(self.app.is_full_screen) def test_app_exit(self): + def exit_handler(widget): + return True + self.app.on_exit = exit_handler + self.assertIs(self.app.on_exit._raw, exit_handler) self.app.exit() self.assertActionPerformed(self.app, 'exit') @@ -104,6 +108,54 @@ def test_full_screen(self): self.app.set_full_screen() self.assertFalse(self.app.is_full_screen) + def test_add_window(self): + test_window = toga.Window(factory=toga_dummy.factory) + + self.assertEqual(len(self.app.windows), 0) + self.app.windows += test_window + self.assertEqual(len(self.app.windows), 1) + self.app.windows += test_window + self.assertEqual(len(self.app.windows), 1) + self.assertIs(test_window.app, self.app) + + not_a_window = 'not_a_window' + with self.assertRaises(TypeError): + self.app.windows += not_a_window + + def test_remove_window(self): + test_window = toga.Window(factory=toga_dummy.factory) + self.app.windows += test_window + self.assertEqual(len(self.app.windows), 1) + self.app.windows -= test_window + self.assertEqual(len(self.app.windows), 0) + + not_a_window = 'not_a_window' + with self.assertRaises(TypeError): + self.app.windows -= not_a_window + + test_window_not_in_app = toga.Window(factory=toga_dummy.factory) + with self.assertRaises(AttributeError): + self.app.windows -= test_window_not_in_app + + def test_app_contains_window(self): + test_window = toga.Window(factory=toga_dummy.factory) + self.assertFalse(test_window in self.app.windows) + self.app.windows += test_window + self.assertTrue(test_window in self.app.windows) + + def test_window_iteration(self): + test_windows = [ + toga.Window(id=1, factory=toga_dummy.factory), + toga.Window(id=2, factory=toga_dummy.factory), + toga.Window(id=3, factory=toga_dummy.factory), + ] + for window in test_windows: + self.app.windows += window + self.assertEqual(len(self.app.windows), 3) + + for window in self.app.windows: + self.assertIn(window, test_windows) + class DocumentAppTests(TestCase): def setUp(self): diff --git a/src/core/tests/test_window.py b/src/core/tests/test_window.py index 65550e6516..cdf94674db 100644 --- a/src/core/tests/test_window.py +++ b/src/core/tests/test_window.py @@ -10,12 +10,17 @@ class TestWindow(TestCase): def setUp(self): super().setUp() self.window = toga.Window(factory=toga_dummy.factory) + self.app = toga.App('test_name', 'id.app', factory=toga_dummy.factory) + + def test_raises_error_when_app_not_set(self): + self.app = None + with self.assertRaises(AttributeError): + self.window.show() def test_widget_created(self): self.assertIsNotNone(self.window.id) - app = toga.App('test_name', 'id.app', factory=toga_dummy.factory) new_app = toga.App('error_name', 'id.error', factory=toga_dummy.factory) - self.window.app = app + self.window.app = self.app with self.assertRaises(Exception): self.window.app = new_app @@ -62,8 +67,19 @@ def test_full_screen_set(self): def test_on_close(self): with patch.object(self.window, '_impl'): - self.window.on_close() - self.window._impl.on_close.assert_called_once_with() + self.app.windows += self.window + self.assertIsNone(self.window._on_close) + + # set a new callback + def callback(window, **extra): + return 'called {} with {}'.format(type(window), extra) + + self.window.on_close = callback + self.assertEqual(self.window.on_close._raw, callback) + self.assertEqual( + self.window.on_close('widget', a=1), + "called with {'a': 1}" + ) def test_close(self): with patch.object(self.window, "_impl"): diff --git a/src/core/toga/app.py b/src/core/toga/app.py index 4a8fbf09b2..da96df3105 100644 --- a/src/core/toga/app.py +++ b/src/core/toga/app.py @@ -3,6 +3,7 @@ import warnings import webbrowser from builtins import id as identifier +from collections.abc import MutableSet from email.message import Message from toga.command import CommandSet @@ -22,18 +23,72 @@ warnings.filterwarnings("default", category=DeprecationWarning) +class WindowSet(MutableSet): + """ + This class represents windows of a toga app. A window can be added to app + by using `app.windows.add(toga.Window(...))` or `app.windows += toga.Window(...)` + notations. Adding a window to app automatically sets `window.app` property to the app. + """ + + def __init__(self, app, iterable=None): + self.app = app + self.elements = set() if iterable is None else set(iterable) + + def add(self, window: Window) -> None: + if not isinstance(window, Window): + raise TypeError("Toga app.windows can only add objects of toga.Window type") + # Silently not add if duplicate + if window not in self.elements: + self.elements.add(window) + window.app = self.app + + def discard(self, window: Window) -> None: + if not isinstance(window, Window): + raise TypeError("Toga app.windows can only discard an object of a toga.Window type") + if window not in self.elements: + raise AttributeError("The window you are trying to remove is not associated with this app") + self.elements.remove(window) + + def __iadd__(self, window): + self.add(window) + return self + + def __isub__(self, other): + self.discard(other) + return self + + def __iter__(self): + return iter(self.elements) + + def __contains__(self, value): + return value in self.elements + + def __len__(self): + return len(self.elements) + + class MainWindow(Window): _WINDOW_CLASS = 'MainWindow' def __init__(self, id=None, title=None, position=(100, 100), size=(640, 480), toolbar=None, resizeable=True, minimizable=True, - factory=None): + factory=None, on_close=None): super().__init__( id=id, title=title, position=position, size=size, toolbar=toolbar, resizeable=resizeable, closeable=True, minimizable=minimizable, - factory=factory + factory=factory, on_close=on_close, ) + @Window.on_close.setter + def on_close(self, handler): + """Raise an exception: on_exit for the app should be used instead of + on_close on main window. + + Args: + handler (:obj:`callable`): The handler passed. + """ + raise AttributeError("Cannot set on_close handler for the main window. Use the app on_exit handler instead") + class App: """ @@ -83,6 +138,8 @@ class App: :param startup: The callback method before starting the app, typically to add the components. Must be a ``callable`` that expects a single argument of :class:`toga.App`. + :param windows: An iterable with objects of :class:`toga.Window` that will + be the app's secondary windows. :param factory: A python module that is capable to return a implementation of this class with the same name. (optional & normally not needed) """ @@ -100,6 +157,7 @@ def __init__( home_page=None, description=None, startup=None, + windows=None, on_exit=None, factory=None, ): @@ -226,6 +284,11 @@ def __init__( self._startup_method = startup self._main_window = None + # In this world, TogaApp.windows would be a set-like object + # that has add/remove methods (including support for + # the + and += operators); adding a window to TogaApp.windows + # would assign the window to the app. + self.windows = WindowSet(self, windows) self._on_exit = None self._full_screen_windows = None @@ -355,7 +418,7 @@ def icon(self, icon_or_name): @property def main_window(self): """ - The main windows for the app. + The main window for the app. :returns: The main Window of the app. """ @@ -364,7 +427,7 @@ def main_window(self): @main_window.setter def main_window(self, window): self._main_window = window - window.app = self + self.windows += window self._impl.set_main_window(window) @property @@ -450,7 +513,15 @@ def main_loop(self): def exit(self): """ Quit the application gracefully. """ - self._impl.exit() + if self.on_exit: + should_exit = self.on_exit(self) + else: + should_exit = True + + if should_exit: + self._impl.exit() + + return should_exit @property def on_exit(self): diff --git a/src/core/toga/window.py b/src/core/toga/window.py index 2f67154835..30ae797696 100644 --- a/src/core/toga/window.py +++ b/src/core/toga/window.py @@ -1,6 +1,7 @@ from builtins import id as identifier from toga.command import CommandSet +from toga.handlers import wrapped_handler from toga.platform import get_platform_factory @@ -24,7 +25,7 @@ class Window: def __init__(self, id=None, title=None, position=(100, 100), size=(640, 480), toolbar=None, resizeable=True, - closeable=True, minimizable=True, factory=None): + closeable=True, minimizable=True, factory=None, on_close=None): self._id = id if id else identifier(self) self._impl = None @@ -50,6 +51,9 @@ def __init__(self, id=None, title=None, self.position = position self.size = size self.title = title + self._on_close = None + if on_close is not None: + self.on_close = on_close @property def id(self): @@ -167,6 +171,8 @@ def position(self, position): def show(self): """ Show window, if hidden """ + if self.app is None: + raise AttributeError("Can't show a window that doesn't have an associated app") self._impl.show() @property @@ -178,12 +184,28 @@ def full_screen(self, is_full_screen): self._is_full_screen = is_full_screen self._impl.set_full_screen(is_full_screen) + @property + def on_close(self): + """The handler to invoke when the window is closed. + + Returns: + The function ``callable`` that is called on window closing event. + """ + return self._on_close + + @on_close.setter + def on_close(self, handler): + """Set the handler to invoke when the window is closed. + + Args: + handler (:obj:`callable`): The handler to invoke when the window is closing. + """ + self._on_close = wrapped_handler(self, handler) + self._impl.set_on_close(self._on_close) + def close(self): self._impl.close() - def on_close(self): - self._impl.on_close() - ############################################################ # Dialogs ############################################################ diff --git a/src/dummy/toga_dummy/app.py b/src/dummy/toga_dummy/app.py index bc39d21bea..adfd6e4be5 100644 --- a/src/dummy/toga_dummy/app.py +++ b/src/dummy/toga_dummy/app.py @@ -1,10 +1,10 @@ -from .utils import LoggedObject, not_required_on +from .utils import LoggedObject, not_required, not_required_on from .window import Window class MainWindow(Window): - @not_required_on('mobile') - def on_close(self): + @not_required + def toga_on_close(self): self.action('handle MainWindow on_close') diff --git a/src/dummy/toga_dummy/window.py b/src/dummy/toga_dummy/window.py index d313be298e..454ce53792 100644 --- a/src/dummy/toga_dummy/window.py +++ b/src/dummy/toga_dummy/window.py @@ -1,4 +1,4 @@ -from .utils import LoggedObject, not_required_on +from .utils import LoggedObject, not_required, not_required_on class Window(LoggedObject): @@ -34,10 +34,14 @@ def show(self): def set_full_screen(self, is_full_screen): self._set_value('is_full_screen', is_full_screen) - @not_required_on('mobile') - def on_close(self): + @not_required + def toga_on_close(self): self._action('handle Window on_close') + @not_required_on('mobile') + def set_on_close(self, handler): + self._set_value('on_close', handler) + def info_dialog(self, title, message): self._action('show info dialog', title=title, message=message) diff --git a/src/gtk/toga_gtk/app.py b/src/gtk/toga_gtk/app.py index 7670d803b4..5a6b3acab1 100644 --- a/src/gtk/toga_gtk/app.py +++ b/src/gtk/toga_gtk/app.py @@ -39,8 +39,15 @@ def set_app(self, app): # Application name to something other than '__main__.py'. self.native.set_wmclass(app.interface.name, app.interface.name) - def on_close(self, *args): - pass + 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. + return not self.interface.app.exit() class App: @@ -69,7 +76,6 @@ def create(self): # Connect the GTK signal that will cause app startup to occur self.native.connect('startup', self.gtk_startup) self.native.connect('activate', self.gtk_activate) - # self.native.connect('shutdown', self.shutdown) self.actions = None diff --git a/src/gtk/toga_gtk/window.py b/src/gtk/toga_gtk/window.py index 019d28d36e..56a7fc384f 100644 --- a/src/gtk/toga_gtk/window.py +++ b/src/gtk/toga_gtk/window.py @@ -40,7 +40,7 @@ def create(self): self.native = self._IMPL_CLASS() self.native._impl = self - self.native.connect("delete-event", self.gtk_on_close) + self.native.connect("delete-event", self.gtk_delete_event) self.native.set_default_size(self.interface.size[0], self.interface.size[1]) # Set the window deletable/closeable. @@ -96,7 +96,7 @@ def set_content(self, widget): self.native.add(self.layout) # Make the window sensitive to size changes - widget.native.connect('size-allocate', self.on_size_allocate) + widget.native.connect('size-allocate', self.gtk_size_allocate) # Set the widget's viewport to be based on the window's content. widget.viewport = GtkViewport(widget.native) @@ -118,14 +118,26 @@ def show(self): self.interface.content._impl.min_width = self.interface.content.layout.width self.interface.content._impl.min_height = self.interface.content.layout.height - def gtk_on_close(self, widget, data): + def gtk_delete_event(self, widget, data): if self.interface.on_close: - self.interface.on_close() + should_close = self.interface.on_close(self.interface.app) + else: + should_close = True + + if should_close: + self.interface.app.windows -= self.interface + + # 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 - def on_close(self, *args): + def set_on_close(self, handler): pass - def on_size_allocate(self, widget, allocation): + def gtk_size_allocate(self, widget, allocation): # ("ON WINDOW SIZE ALLOCATION", allocation.width, allocation.height) pass diff --git a/src/winforms/toga_winforms/app.py b/src/winforms/toga_winforms/app.py index 72ec3e850e..d2614961ce 100644 --- a/src/winforms/toga_winforms/app.py +++ b/src/winforms/toga_winforms/app.py @@ -14,8 +14,9 @@ class MainWindow(Window): - def on_close(self): - pass + def winforms_FormClosing(self, sender, event): + if not self.interface.app._impl._is_exiting: + event.Cancel = not self.interface.app.exit() class App: @@ -25,6 +26,16 @@ 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 calls app.exit(), which + # will then trigger the "on_exit" handler (which might abort the + # close). However, if app.exit() succeeds, it will request the + # Main Window to close... which calls app.exit(). + # 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. + self._is_exiting = False + self.loop = WinformsProactorEventLoop() asyncio.set_event_loop(self.loop) @@ -184,7 +195,6 @@ def run_app(self): self.create() self.native.ThreadException += self.winforms_thread_exception - self.native.ApplicationExit += self.winforms_application_exit self.loop.run_forever(self.app_context) except: # NOQA @@ -196,10 +206,6 @@ def main_loop(self): thread.Start() thread.Join() - def winforms_application_exit(self, sender, *args, **kwargs): - if self.interface.on_exit is not None: - self.interface.on_exit(sender) - def show_about_dialog(self): message_parts = [] if self.interface.name is not None: @@ -234,6 +240,7 @@ def show_about_dialog(self): ) def exit(self): + self._is_exiting = True self.native.Exit() def set_main_window(self, window): diff --git a/src/winforms/toga_winforms/window.py b/src/winforms/toga_winforms/window.py index feaeb447c7..ccd50b9df2 100644 --- a/src/winforms/toga_winforms/window.py +++ b/src/winforms/toga_winforms/window.py @@ -126,16 +126,27 @@ def show(self): ) self.interface.content.refresh() - self.native.Show() + self.native.FormClosing += self.winforms_FormClosing - def winforms_FormClosing(self, event, handler): - if self.interface.app.on_exit: - self.interface.app.on_exit(self.interface.app) + if self.interface is not self.interface.app._main_window: + self.native.Icon = self.interface.app.icon._impl.native + self.native.Show() + + def winforms_FormClosing(self, sender, event): + if self.interface.on_close: + should_close = self.interface.on_close(self) + else: + should_close = True + + if should_close: + self.interface.app.windows -= self.interface + else: + event.Cancel = True def set_full_screen(self, is_full_screen): self.interface.factory.not_implemented('Window.set_full_screen()') - def on_close(self): + def set_on_close(self, handler): pass def close(self):