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

Add window states API #2473

Open
wants to merge 212 commits into
base: main
Choose a base branch
from
Open

Add window states API #2473

wants to merge 212 commits into from

Conversation

proneon267
Copy link
Contributor

@proneon267 proneon267 commented Apr 2, 2024

Fixes #1857

Design discussion: #1884

The following are the API changes:

On the interface:

toga.constants.WindowState
WindowState.NORMAL
WindowState.MAXIMIZED
WindowState.MINIMIZED
WindowState.FULLSCREEN
WindowState.PRESENTATION
toga.Window
Added toga.Window.state(getter)
toga.Window.state(setter)
Deprecated toga.Window.full_screen(getter)
toga.Window.full_screen(setter)
toga.App
Added toga.App.is_in_presentation_mode(getter)
toga.App.enter_presentation_mode()
toga.App.exit_presentation_mode()
Deprecated toga.App.is_full_screen(getter)
toga.App.set_full_screen()
toga.App.exit_full_screen()

On the backend:

toga.Window
Added get_window_state()
set_window_state()
toga.App
Added enter_presentation_mode()
exit_presentation_mode()

However, I did encounter some issues, which I have put as inline comments. I do have some other questions about rubicon for implementing the new API, but I will post them later on.

TODO: Write and modify documentation, fix issues with tests, implement for the other remaining platforms, etc.

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

@freakboy3742
Copy link
Member

I have split the delegate events into their respective delegates.

I understand that this might be helpful for diagnosis, but in terms of the final PR, a single "object as delegate" is vastly preferable. Object allocation is expensive, so allocating as few objects as possible is the preferred approach unless there's a specific reason why it isn't possible.

But, the errors on the currently failing macOS testbed indicate that interface and impl are still None at certain times. I need to do some more investigation, but roughly I think the problem lies in the app_probe fixture and window_cleanup fixture on the testbed.

I agree a connection with garbage collection is likely; but I don't think it's accurate to say there's a problem with either probe. The probes are doing entirely legal things; they're just forcing a garbage collection run at a specific (and predictable) time. If an object is able to be garbage collected, then the issue is that the object being referenced has (or could be) disposed. That indicates there's a lifecycle issue with Window objects.

This is also consistent with the "remove weak=True" observation - if they were strong references, the objects wouldn't be garbage collected, because there would still be a strong reference.

So - the question is what sequence of events leads to the impl and/or interface being deleted before the native object (and, more importantly, why this set of changes is exposing the problem). Understanding exactly which methods are being invoked in a impl/interace=None state, and under what conditions, would help to clarify the sequence of events leading to that state.

Do the properties becomes a class property(i.e., commonly shared among all instances) or does it become an instance property(i.e., specific to each instance of the class)?

They are instance properties. The slot is defined on the class, but the actual values are instance specific.

@proneon267
Copy link
Contributor Author

proneon267 commented Sep 5, 2024

It seems that my initial intuition about the delegate events getting triggered during the closing of the window during each run of a test, seems to be correct. The delegate events are triggered when NSWindow.close() is called. When NSWindow.close() is triggered then the ObjC GC garbage collects the interface and impl properties and sets them to None, since they are declared as weak references. At this stage, when the delegate events are triggered then they get an AttributeError, since interface and impl have been set to None.

Hence, we need to disconnect the delegate from the NSWindow in order to avoid the delegate events' propagation during the window closing. For this, I have added the delegate event windowWillClose_ to disconnect the delegate in there:

@objc_method
def windowWillClose_(self, notification) -> None:
# Disconnect the delegate before closing to prevent propagation
# of delegate events during a time when the ObjC GC has garbage
# collected interface and impl properties i.e., has set them to
# None, since they are declared as weak references.
self.impl.native.setDelegate(None)

But the delegate events are still getting triggered as evident by the macOS testbed failures. However, the current reason for why the events are still getting triggered is that there is a timing mismatch between disconnecting of the delegate from the NSWindow and the triggering of the delegate events. This is also exacerbated by the usage of performSelector. Even specifying a delay of 0 adds some amount of delay, which can mess up the timing.

I need to research more and check for any other available options in order to avoid triggering the delegate events. To consistently trigger the errors, I have added tests/window/test_failure.py. In the final form of this PR, I will remove all these diagnostic codes.

@proneon267
Copy link
Contributor Author

I have gone through the review of the code related issues. I have added some new tests and fixed test cases in order to have full test coverage.

I know that this PR is relatively large to review at one time. But if time permits, could you please review the tests of core and testbed and let me know about any missing test cases or any required changes.

Also, I am not sure where to put the platform guide for window state in the docs. Should I put it under the "How to guides" section in the docs? In the platform guide, I was thinking of providing a brief usage examples, and to have a table matrix to indicate which states are available on different platforms and mention any platform specific quirks below the table. Does this sound correct?

@freakboy3742
Copy link
Member

I have gone through the review of the code related issues. I have added some new tests and fixed test cases in order to have full test coverage.

I know that this PR is relatively large to review at one time. But if time permits, could you please review the tests of core and testbed and let me know about any missing test cases or any required changes.

I'll put it on my TODO list for early this week.

Also, I am not sure where to put the platform guide for window state in the docs. Should I put it under the "How to guides" section in the docs? In the platform guide, I was thinking of providing a brief usage examples, and to have a table matrix to indicate which states are available on different platforms and mention any platform specific quirks below the table. Does this sound correct?

It doesn't need to anything near that elaborate. The platform guide is the Notes section of the relevant widget (Window, in this case). It only needs to list the details that might be of interest, on a per-platform basis. Window already contains some of these platform notes. Add a similar bullet point for window state changes; or, if you think it's clearer, split it into a bullet point for each platform detailing that platform's specific quirks.

It definitely doesn't need to be a table, or example code, or anything like that. For a feature as straightforward as this, if the usage isn't obvious from the API, we've designed a bad API. There might be a need for a section in the docs to detail the difference between "full sreen" and "presentation" mode - but even that is largely covered by the enumeration of window states.

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 got part way through a review, and then discovered a bunch of comments from past reviews that haven't had any response, then realised you'd only asked for a review of the core and testbed tests - so you got an extra review of most of Android and Cocoa for free :-)

As for the state of the tests... they're definitely getting better, but there are still some problems. Details inline; but the biggest problem of all is the use of random in selecting test conditions - that's an absolute non-starter.

The biggest thing of all to keep in mind is "why do I need to add another test?". There should be no unique feature that we are testing in multiple ways. We should only be adding a new test case when there is a unique set of pre or post conditions that we need to evaluate. To that end, there may be different parameterization strategies required. In some cases, a literal X * Y matrix will be the right approach; but in others, Y tests, parameterized by X will be the best approach.

pass
def enter_presentation_mode(self, screen_window_dict):
for screen, window in screen_window_dict.items():
window._impl._before_presentation_mode_screen = window.screen
Copy link
Member

Choose a reason for hiding this comment

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

What is this variable in aid of? It doesn't appear to be used anywhere.

pass
def exit_presentation_mode(self):
# There is only a single window on android.
self.interface.main_window._impl.set_window_state(WindowState.NORMAL)
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't seem like a full reversal. It's somewhat a moot point because you can only have one window in an Android app; but given the "enter" method supports multiple screens, it seems like this should reverse any screen change (or the enter method shouldn't worry about screen changes).

decor_view = self.app.native.getWindow().getDecorView()

if current_state == state:
return
Copy link
Member

Choose a reason for hiding this comment

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

It seems like this should be an optimisation at the core level - it's something that will be common to every implementation.

| decor_view.SYSTEM_UI_FLAG_IMMERSIVE
)
self.show_actionbar(False)
self._in_presentation_mode = True
Copy link
Member

Choose a reason for hiding this comment

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

Is there any reason that _in_presentation_mode shouldn't be set by show_actionbar()? It seems like that's really what is being tracked here.

async def wait_for_window(self, message, minimize=False, full_screen=False):
await self.redraw(message)
async def wait_for_window(
self, message, minimize=False, full_screen=False, rapid_state_switching=False
Copy link
Member

Choose a reason for hiding this comment

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

When the argument list gets too long, we try to break the arguments into separate lines, rather than the "all on one line indented" format. Black will auto-format this if you add a comma to the end of the argument list.

assert main_window.state == final_state
finally:
# Set to NORMAL state
main_window.state = WindowState.NORMAL
Copy link
Member

Choose a reason for hiding this comment

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

It seems like this main_window.state == NORMAL and app.current_window = main_window cleanup tasks should be part of the window_cleanup fixture - so that every test can guarantee that it leaves the app in a clean state.

(WindowState.PRESENTATION, WindowState.FULLSCREEN),
],
)
async def test_window_state_direct_change(
Copy link
Member

Choose a reason for hiding this comment

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

Why differentiate "direct" from "same" state changes? We're looking to validate that moving from X to Y works - for any value of X and Y, including X == Y. In what way are the "same state" tests different?

main_window.content.clear()

async def test_window_state_presentation(main_window, main_window_probe):
"""The window can enter into presentation state."""
Copy link
Member

Choose a reason for hiding this comment

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

How is this any different to the "NORMAL->PRESENTATION" state change test?

WindowState.MAXIMIZED,
WindowState.FULLSCREEN,
WindowState.PRESENTATION,
],
Copy link
Member

Choose a reason for hiding this comment

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

As with the mobile variant - it's not clear to me how an X->Y test is different from an X->X test.

"""Window can be made full screen"""
assert not second_window_probe.is_full_screen
async def test_window_state_minimized(second_window, second_window_probe):
"""Window can have minimized window state."""
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need explicit tests of minimized state when we've already validated every possible transition?

Or, if the issue is that there are properties of the final minimized state that need to be validated (which seems possible) - then we should have a standalone test case for each final state, parameterised by original state, rather than a single full x->y matrix test.

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.

Window maximize & minimize API functionality
3 participants