Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor window closing and app exit handling #980

Merged
merged 50 commits into from
May 29, 2021

Conversation

obulat
Copy link
Contributor

@obulat obulat commented Jul 22, 2020

Signed-off-by: Olga obulat@gmail.com

When #741 added asyncio handling in winforms backend, it accidentally removed lines that were used to handle app exit event (added in #522) and to show a secondary window (added in #432). This PR adds them back. I tested adding an on_exit handler, but didn't test adding secondary window.

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change looks great. However, we should add an example that demonstrates "opening new window" functionality. This will help testing this specific change, and will help ensure the problem doesn't re-occur in future.

We could probably add this to the existing dialogs demo - in addition to all the other "open dialog" buttons, add a "show new window" button that pops up a new window. Include a counter in the title of the window (so you get "New window 1" then "New window 2"); and hook in some on close logic on the window so that when it is closed, it updates a label on the main window that says which window was closed.

Signed-off-by: Olga <obulat@gmail.com>
Winforms YesNo MessageBox returns an enum value of type WinForms.DialogResult. Previously, numerical value (either 6 or 7) of this enum was returned, which caused question dialog in Winforms to always return true.

Signed-off-by: Olga <obulat@gmail.com>
A common scenario for app.exit event is to confirm that the user really wants to exit. If the user answers no, we need to have an option of cancelling closing the app. This change provides a possibly-crossplatform way of cancelling exit, currently only on winforms.

Signed-off-by: Olga <obulat@gmail.com>
This app does not reuse buttons in secondary window to avoid the bug related to winforms not having `remove_child` method (when reusing a button from the main window in the secondary window, the app is trying to remove it from the main window first). Also, the question dialog handling app exit is replaced by confirm dialog.

Signed-off-by: Olga <obulat@gmail.com>
Signed-off-by: Olga <obulat@gmail.com>
Currently Toga has on_exit handler which is invoked when main window is closed in Winforms. This commit adds a handler for close event for secondary windows, as well as the example usage in dialog app. If this change is accepted, it needs to be implemented in other backends.

Signed-off-by: Olga <obulat@gmail.com>
@obulat
Copy link
Contributor Author

obulat commented Jul 23, 2020

We could probably add this to the existing dialogs demo - in addition to all the other "open dialog" buttons, add a "show new window" button that pops up a new window. Include a counter in the title of the window (so you get "New window 1" then "New window 2"); and hook in some on close logic on the window so that when it is closed, it updates a label on the main window that says which window was closed.

I realized that this example needs additional functionality: we had on_exit for main_window closing, but no handler for the event of closing a secondary window. I've added on_close handler to core and winforms backend.

Here's a screenshot with two secondary windows open:
windows

Signed-off-by: Olga <obulat@gmail.com>
Signed-off-by: Olga <obulat@gmail.com>
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This demo is really good; I've flagged a couple of minor things, but for the most part it's really good.

The bigger questions are about the APIs that we use to make this happen. The on_close API you've suggested is definitely required; however, we need to be careful that it doesn't collide with an on_close API that already exists (or, at least, that we manage that collision). macOS already has an on_close method that it uses as part of the way it shuts down apps.

Working out the interaction between "on_close" and "on_exit", and the logic for confirming app exit (and window close, I guess) exit is also something we need to get right. I'm almost inclined to suggest that we don't need on_close and on_exit - we only need on_close, with on_close on the main window being application close. I'd also be inclined to use the return value of on_close as the trigger for whether the window/app should be allowed to close.

Does that make sense?

@@ -90,12 +90,53 @@ def action_save_file_dialog(self, widget):
except ValueError:
self.label.text = "Save file dialog was canceled"

def action_open_secondary_window(self, widget):
# Problems:
# - Setting secondary window's `app` manually
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good point - how do we tell the window about the app?

The MainWindow gets assigned to the app when you set self.main_window = .... I wonder if we should do a similar thing for other windows, and require that they are explicitly added to the app:

self.windows += toga.Window(...)

or:

self.windows.add(toga.Window(...))

and raise an error if you attempt to call show() on a window that doesn't have an app assigned.

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.

Does that make sense as an API?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a possible implementation of self.windows, could you take a look?
The example doesn't count windows correctly because the handling of window on_close is incorrect at the moment.

examples/dialogs/dialogs/app.py Outdated Show resolved Hide resolved
examples/dialogs/dialogs/app.py Outdated Show resolved Hide resolved
window.on_close = close_handler
window.show()

def exit_handler(self, app, cancel_exit):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good idea, but we need to differentiate two things here: an event handler that is triggered when an app is exiting, and an event handler that is triggered to determine if an app can exit. Some platforms do this with two different handlers (macOS uses willClose and didClose); others use a boolean return value from the handler (true means the app can exit).

Passing in a callable definitely works in the windows case, but I'm not sure it's the best API overall.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally thought that we may not need an public facing on_exit, but thinking about it some more, I think it's worth having a separate on_exit handler - if only because Document based apps (like Podium) don't have a main window, but obviously they still exit.

When I tried to implement Document-based app (Podium) on Windows, I used a main Form that has IsMdiContainer (is Multiple Document Interface Container) set to true for the container window, and set it as the main window; and secondary windows (Forms) that have MdiParent property set to the container Form. What's the best way of handling this difference between Mac and Windows re: main window presence/ absence?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I'm not 100% sure what the "right" approach is on Windows.

On macOS, the App runs exactly once; it always has a menu at the application level; and if you haven't got a document open, a file dialog opens prompting you to open a document. Each Document that is open has a primary window.

On Linux, each "document" is a separate instance of the running app. Each document has a main window with a menu.

I haven't used Windows for a while, so I'm not 100% sure what users would expect - whether it's closer to the Apple model (but with a menu on each document window), or Linux, somewhere between the two, or something completely different. I remember MDI apps from the days of Windows 3.1 - but that was a "main" window that contained internal sub-windows - but that style of app hasn't been common for a long time, AFAIK. If Winforms concept of MDI is different, it might be the right approach; but it would be helpful to see what that looks like to compare with other Windows apps.

The best suggestion I've got is to step away from Toga for a moment, and try and build an app directly with Python.NET that has the right behavior. The app doesn't need to actually do anything - putting up a window with a label that says "I am document N". That way we can make sure we have the basic ideas right before we worry about integrating with Toga.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got interested, and dug into this a little more. If Notepad is any indication, the Linux approach might be the right one. If you open to two documents, there are two instances of notepad.exe. So, that should make things a lot easier - we can keep the same "main" window infrastructure, and wrap it with some file handling. The Linux implementation should also be a fairly good point of reference.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are right that Windows is closer to Linux here. One point I would add is usually, when you have a document open in an app, and want to open a new one, Windows doesn't start a new app instance, it just replaces the document in the main window of the previous app. Maybe, @brettcannon could answer with more certainty?..

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am most definitely not a Windows expert so I can't really answer what the UX should be here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, sorry about this misunderstanding on my part.

@obulat
Copy link
Contributor Author

obulat commented Jul 27, 2020

This demo is really good; I've flagged a couple of minor things, but for the most part it's really good.

Thank you!

I'm almost inclined to suggest that we don't need on_close and on_exit - we only need on_close, with on_close on the main window being application close. I'd also be inclined to use the return value of on_close as the trigger for whether the window/app should be allowed to close.

Do you mean that we don't need on_close and on_exit on the interface level, or the user-facing API? I believe that the user-facing API needs both, and on the interface level we can handle both with a single on_close handler like this:

def on_close():
    if window is main window:
        self._impl.on_exit()
    else:
        self._impl.on_close()

Is this what you meant?

@freakboy3742
Copy link
Member

I originally thought that we may not need an public facing on_exit, but thinking about it some more, I think it's worth having a separate on_exit handler - if only because Document based apps (like Podium) don't have a main window, but obviously they still exit.

Part of the cleanup here needs to be clarifying the difference between the "internal" implementation on_close and the "public" interface on_close. The fact that we're having confusion over which on_close method we need is an indication of a deeper problem :-) The "gtk_"/"winforms_" convention for native signals might be a pattern to emulate here; if we pick a prefix for "internal" signals, we can avoid colliding in future.

Toga app's windows property allows it to keep track of all its windows.

Signed-off-by: Olga <obulat@gmail.com>
If a user adds on_close handler to an app, it overwrites the on_close in the interface, so we cannot handle window close event (to remove the window from app.windows) in toga. Addition of toga_on_close allows to have both user and toga window closing handler.

Signed-off-by: Olga <obulat@gmail.com>
Signed-off-by: Olga <obulat@gmail.com>
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't sure if you wanted another code review on this yet; I took a quick look anyway, and it's looking really good - definitely headed in the right direction. I can see a couple of TODOs, and I'm guessing you'll need a review of the coca interpretation of this (IIRC, you don't have access to a macOS machine for development purposes?) - Let me know when you're ready for a deep review/macOS check.

src/core/toga/window.py Outdated Show resolved Hide resolved
…nd triggering user's on_close) to implementation side

Signed-off-by: Olga <obulat@gmail.com>
Signed-off-by: Olga <obulat@gmail.com>
Signed-off-by: Olga <obulat@gmail.com>
Signed-off-by: Olga <obulat@gmail.com>
@obulat
Copy link
Contributor Author

obulat commented Aug 19, 2020

I've pushed some additional changes just to make the tests pass, but this way there are too many 'close' related functions everywhere.

I think I've finally understood what you meant when you said we don't need a separate on_exit handler. We can set separate on_close handlers for each window (main and secondary) separately, and the on_close for main window would handle the exit case.

And yes, for Winforms this makes sense, and we don't need a separate handler. But we need it for document apps. So, what would be the best API for users?

I had an idea that on_exit is for things you need to finish up before closing the app: saving some data, and maybe prompting the user if they really want to exit. For these actions, the word 'exit' seems more appropriate. And this distinction would be really clear between actions that need to happen when the app with all of its windows is closing, and when simply one window closes (when the window's on_close handler would be called).

@saroad2
Copy link
Member

saroad2 commented Sep 27, 2020

Merged from master to solve merging conflicts.

What's missing in order to merge this PR?

obulat added 6 commits May 8, 2021 11:51
Signed-off-by: Olga Bulat <obulat@gmail.com>
Signed-off-by: Olga Bulat <obulat@gmail.com>
Signed-off-by: Olga Bulat <obulat@gmail.com>
Signed-off-by: Olga Bulat <obulat@gmail.com>
Signed-off-by: Olga Bulat <obulat@gmail.com>
Signed-off-by: Olga Bulat <obulat@gmail.com>
@obulat obulat requested a review from freakboy3742 May 13, 2021 04:02
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is starting to look really good. I've flagged a few things inline; I'll push an update shortly that addresses most of them.

I still need to test GTK; but in the meantime, there's two issues pending that I can see.

  1. There's a weird edge case in Cocoa handling. MainWindow.toga_on_close invokes exit, rather than running the on_exit handler. As a result, it's possible to approve the main window to close, but deny the app from exiting - which results in a running app with no main window.

  2. On winforms, the close action doesn't seem to fully honour a "don't close" from the MainWindow. If you say you don't want to close the main window, there's no point to asking if you want the app to exit.

This does make me wonder whether we should perhaps not have a user-space on_close handler for MainWindow - since on_close on the main window is an exit, it perhaps doesn't make sense to have both.

examples/dialogs/dialogs/app.py Show resolved Hide resolved
@@ -36,16 +35,14 @@


class MainWindow(Window):
def on_close(self):
def toga_on_close(self):
# TODO: do not call app's exit if this is a Document App
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A DocumentApp won't have a MainWindow; there's a window for each Document, so this isn't a concern.

@@ -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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type annotation here is significant. Without it, the return type is assumed to be "object", and every value returned will be true. windowShouldClose: returns Boolean; so if we want True/False to be interpreted correctly, the method needs to be annotated -> bool.

pass

def toga_on_close(self):
self.interface.app.windows -= self.interface
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an edge case here. This will unconditionally remove the window from the app; but if you return False in your on_close handler, the window won't be closed. That means we end up with a window that isn't being tracked.

def toga_on_close(self):
# TODO: do not call app's exit if this is a Document App
if self.interface.on_close:
return self.interface.on_close(self)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This effectively duplicates the logic of Window.toga_on_close; ideally, we'd re-use the base class logic, and add the extra handling for allowing/starting the exit.

Copy link
Contributor Author

@obulat obulat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does make me wonder whether we should perhaps not have a user-space on_close handler for MainWindow - since on_close on the main window is an exit, it perhaps doesn't make sense to have both.

I've been thinking about it the use-cases for having separate on_close for the MainWindow and on_exit for the App in a non-Document-app, and I don't see any. I think only having on_close for secondary windows, and on_exit for the MainWindow closing is the best option, I will add that.

@freakboy3742
Copy link
Member

One additional suggestion around removing on_close for MainWindow - since MainWindow is a subclass of Window, there will still be an API that would allow a user to set on_close. Ideally, we'd either disable that capability for on_close - or, alternatively, raise an error if someone tries to set an on_close handler for a main window (or, at the very least, have the toga_on_close platform handler look for an on_close and raise a warning that it's going to be ignored)

Signed-off-by: Olga Bulat <obulat@gmail.com>
Signed-off-by: Olga Bulat <obulat@gmail.com>
Signed-off-by: Olga Bulat <obulat@gmail.com>
@obulat obulat requested a review from freakboy3742 May 23, 2021 18:10
@obulat
Copy link
Contributor Author

obulat commented May 23, 2021

I removed the on_close from the main window, leaving only the app's on_exit. Trying to set an on_close on the main window raises an AttributeError (?) now.
There was a problem in Winforms where when the window closing is canceled, the window is closed anyways. I fixed that, and also fixed the example app showing the wrong number of open secondary windows in the label.

@obulat obulat changed the title Re-add on_exit event handler; call show for second window to winforms backend Refactor window closing and app exit handling May 24, 2021
Copy link
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really good - thanks for all the hard work!

I've made a couple of small tweaks:

  • I've added some docstrings to explain why how the example app works
  • I've consolidated the handling of on_exit() into the interface layer - after all, every app should be calling on_exit in the same way
  • I've tweaked the naming of the toga_on_close handler. For every other event, we've used the platform prefix (gtk_, winforms_ etc) to identify internal system native events; in this patch, all the event handlers are internal, so it makes sense (to me, anyway!) to preserve that naming. That also helps make it clear when you're handling a "toga" event or a "native" event.
  • I've tweaked the logic around the GTK backend - it wasn't doing the on_exit check on app close.

Thanks for all your persistence on this - it's been a long time in the making, and we wouldn't have got here without all your effects!

@codecov
Copy link

codecov bot commented May 29, 2021

Codecov Report

Merging #980 (cabef46) into master (19e89a7) will increase coverage by 0.05%.
The diff coverage is 94.00%.

Impacted Files Coverage Δ
src/core/toga/window.py 98.95% <90.90%> (-1.05%) ⬇️
src/core/toga/app.py 85.92% <94.87%> (+2.59%) ⬆️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants