Repository: beeware/toga

Issue:
Windows with `closeable = False` cannot be closed programatically on macOS
**Describe the bug**
When `closeable = False` is set for a `toga.Window`, the window does not show a close button. However, it should still be possible to close it programatically using `toga.Window.close()`. Since the changes introduced in https://github.com/beeware/toga/pull/980, this no longer works on macOS because the implementation layer now uses the native `NSWindow.performClose_()` method instead of `NSWindow.close()`. `NSWindow.performClose_()` mimics a user press on the close button, together with calling the delegate method `windowShouldClose_()`, and therefore fails when there is no close button.

I am not sure if this an actual bug since we are in fact getting what we are asking for with `closeable = False`. However, it certainly is an unexpected and maybe unintended change in behaviour.

The typical use case for a window without a close button is a dialog, where the user can press `Ok`, `Cancel`, etc, and some of those button may trigger a programmatic close which is no longer possible.

**To Reproduce**
Minimal code to reproduce:

```Python
import toga
from toga.constants import ROW
from toga.style import Pack


class ExampleApp(toga.App):
    def startup(self):

        self.window = toga.Window(
            title=self.name, size=(800, 500),
            resizeable=False,
            minimizable=False,
            closeable=False,
        )

        self.window.content = toga.Box(
            style=Pack(direction=ROW),
            children=[
                toga.Button(label="Close", on_press=self.close_pressed),
            ],
        )
        self.app.windows += self.window

        self.window.show()

    def close_pressed(self, sender):
        self.window.close()


if __name__ == '__main__':
    ExampleApp("App", "org.beeware.widgets.app").main_loop()

```

**Expected behavior**
I would expect a programatic close of a window to work even when it does not have a close button.

**Environment:**
 - Operating System: macOS
 - Python version: Python 3.9.5
 - Software versions:
   - Toga: master




Discussion:
This *was* intentional, but I'm willing to be convinced the behavior isn't correct (or isn't fully correct as implemented).

The general line of thought was this: on a MainWindow-based app (i.e., not a DocumentApp), closing the MainWindow is the same as quitting the app. This is more obvious on Windows/Linux where this is literally true (you can't have an app at all that doesn't have a window); on macOS, the "close of MainWindow == exit" is a specific interpretation for MainWindow.

This is the reason that there isn't an `on_close` handler for MainWindow; there's only `on_exit` handling for the app. 

In that light... I'm not sure what `closeable=False` would even means for MainWindow. The usage on child windows effectively controls whether there is a close button (or an active one); that's the behavior that MainWindow is inheriting... but does that even make sense for a MainWindow? Should you be able to mark a Main Window as closable? Should it *always* be marked as non-closeable? And should `MainWindow.close()` be treated as a direct proxy for `App.exit()` (on the basis that this is what the user is obviously asking for?)

I don't have firm answers for these questions - interesting in your take on them.

Ah, ok. In that case, please excuse the "bug report" format.

> This is the reason that there isn't an on_close handler for MainWindow; there's only on_exit handling for the app.

That makes sense.

> I'm not sure what closeable=False would even means for MainWindow. 

Well, I don't think it is particularly useful at all for a MainWindow, it might even make sense to remove this argument for the MainWindow in the first place. I also quite like the idea of linking `MainWindow.close()` to `App.exit()`. Both of this will remove choices from the user and impose our / the platform's idea of how a MainWindow-based app should behave. But this needn't be a bad thing.

My point is rather about secondary windows. If you look at the provided example, it explicitly does not create a `MainWindow` but a regular `Window`. I think this boils down to identifying the use case for `closeable = False` in the first place. Personally I am using it for custom dialog windows which do not show traffic light buttons but rather force the user to press one of the dialog buttons to proceed. Such custom dialog windows make sense when you want to present a more complex UI. Good examples are the native macOS stack trace / crash dialog or a progress bar window with a cancel button (e.g., Sparkle's "Checking for updates"). Personally, I am using this as follows:

<img width="562" alt="Screenshot 2021-08-14 at 09 54 14" src="https://user-images.githubusercontent.com/20974290/129440654-208210b2-8cce-4134-9efa-a584e0ca02e5.png">

Such dialogs can be implemented with toga by using a regular window and calling `Window.close()` in the button callback as appropriate. However, `Window.close()` now fails in this case because it internally uses `NSWindow.performClose_()` and there is no close button!
Ah - my apologies - I saw the reference to #980 and assumed this was entirely about close and MainWindow without fully digesting the example code. So everything I said is true... but not related to what you asked :-)

I completely agree that on *secondary* windows, `closeable` makes sense, dialogs like the ones you've described are a. major use case, and a programmatic `close()` API should work - so what you've highlighted is definitely a bug. 

That said: I'm also wondering if this particular use case (the "complex" dialog) needs an API of it's own. While you can definitely produce something dialog-like as a secondary window, the resulting window *won't* be a modal dialog - and something like the "access revoked" dialog example you've provided should almost certainly be modal.

> I'm also wondering if this particular use case (the "complex" dialog) needs an API of it's own.

Possibly. The way I have handled this is by subclassing `Window` and adding a (synchronous) API `Window.show_as_sheet(window)` which shows the `Window` as modal "sheet" on top of `window`. One could generalise this API to allow both app-modal and document-modal dialogs with an async method `Window.show_modal(window=None)`. This could potentially be coupled with a method `Window.end_modal(result)` which can be used to set a dialog's return value and end the modal session, instead of the simple `Window.close()` which I have been using.

The beauty of this: It allows *any* window to act as a dialog, given the user a lot flexibility. The downside is: It requires manual handling of dialog buttons and return values and allows the user to stray from the HIG if they want.

In any case, this discussion is probably better suited for another feature request. It also ties in to a general discussion about dialogs: they are currently all synchronous and app-modal only. Both might need to be revisited...

Resolving patch:
diff --git a/src/cocoa/toga_cocoa/window.py b/src/cocoa/toga_cocoa/window.py
--- a/src/cocoa/toga_cocoa/window.py
+++ b/src/cocoa/toga_cocoa/window.py
@@ -266,9 +266,10 @@ def cocoa_windowShouldClose(self):
         return should_close
 
     def close(self):
-        # Calling performClose instead of close ensures that the on_close
-        # handlers in the delegates will be called
-        self.native.performClose(self.native)
+        # Close window directly here, don't use `NSWindow.performClose()`
+        # because it won't work if the window does not have a close button.
+        if self.cocoa_windowShouldClose():
+            self.native.close()
 
     def info_dialog(self, title, message):
         return dialogs.info(self.interface, title, message)
diff --git a/src/core/toga/window.py b/src/core/toga/window.py
--- a/src/core/toga/window.py
+++ b/src/core/toga/window.py
@@ -186,19 +186,21 @@ def full_screen(self, is_full_screen):
 
     @property
     def on_close(self):
-        """The handler to invoke when the window is closed.
+        """The handler to invoke before the window is closed.
 
         Returns:
-            The function ``callable`` that is called on window closing event.
+            The function ``callable`` that is called before the window is closed.
         """
         return self._on_close
 
     @on_close.setter
     def on_close(self, handler):
-        """Set the handler to invoke when the window is closed.
+        """Set the handler to invoke when before window is closed. If the handler
+        returns ``False``, the window will not be closed. This can be used for example
+        for confirmation dialogs.
 
         Args:
-            handler (:obj:`callable`): The handler to invoke when the window is closing.
+            handler (:obj:`callable`): The handler to invoke before the window is closed.
         """
         self._on_close = wrapped_handler(self, handler)
         self._impl.set_on_close(self._on_close)


Test patch: