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 initial macOS probe implementation #1707

Merged
merged 42 commits into from
Jan 23, 2023

Conversation

freakboy3742
Copy link
Member

An initial set of test probes for macOS.

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 freakboy3742 requested a review from mhsmith December 8, 2022 06:55
cocoa/tests_backend/widgets/base.py Outdated Show resolved Hide resolved
Comment on lines 23 to 29
@property
def background_color(self):
return toga_color(self.native.backgroundColor)

@property
def color(self):
return toga_color(self.native.textColor)
Copy link
Member

Choose a reason for hiding this comment

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

On the other platforms I tried to make the probe hierarchy follow the native widget hierachy. For example, on Windows the base Control class has a ForeColor property, so the SimpleProbe implements the color method. But on Android there is no such property, so SimpleProbe.color calls skip, and the widget-specific probes override the method where applicable.

If I understand the Cocoa documentation correctly, there are no color properties in the base NSView class, so we should take the second approach.

Copy link
Member

Choose a reason for hiding this comment

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

But maybe I've misunderstood something, because if NSButton doesn't have a backgroundColor property, then how did it pass test_button.py::test_background_color?

Copy link
Member

Choose a reason for hiding this comment

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

I guess that's what this page calls a "cover" method for NSButtonCell. But I still can't see where drawsBackground is coming from.

Copy link
Member Author

Choose a reason for hiding this comment

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

Organising probe APIs to match the underlying platforms makes sense. It does open up an interesting gap where some platforms will have a "color" API on the base widget, but on others it will be platform specific; but I guess that will manifest as a test failure/error due to missing a required probe API.

You're correct that backgroundColor is a cover API; however, it looks like the base widget usage of drawsBackground is an accidental pass. drawsBackground only exists on a few widgets (NSTextField and NSScrollView being the most notable); it doesn't exist on NSButton (even as a cover method), but you can set an arbitrary property on an Rubicon ObjC object and it will just be annotated on the object.

However - this becomes an interesting case of somewhere that checking properties won't necessarily tell us that something is working correctly. This is the current output of the colors example on macOS, with a background color of green, and a color of blue.

Screen Shot 2022-12-09 at 9 42 58 am

The button does have something that could be called the background color altered, and the backgroundColor property is correct; but it's not changing the background color (at least, not in the way you'd expect).

I guess this comes down to verifying that the behaviour (and the probe) is actually correct at the time of implementation.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, it'll be the responsibility of the probes to check whatever native properties are appropriate for each widget.

Copy link
Member

Choose a reason for hiding this comment

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

For future reference: see related discussion below.

Comment on lines 15 to 20
assert getattr(actual, component) == getattr(
expected, component
), f"actual={actual} != expected={expected}"
assert actual.a == approx(
expected.a, abs=(1 / 255)
), f"actual={actual} != expected={expected}"
Copy link
Member

Choose a reason for hiding this comment

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

pytest is supposed to print the actual and expected values automatically, but I guess it didn't work because the "assertion rewriting" feature is only on by default for code directly inside of test modules.

I previously tried fixing this by adding the following to tests/__init__.py, but it didn't have any effect, at least on Windows:

import pytest
pytest.register_assert_rewrite("tests")

I think we should get to the bottom of this and fix it properly rather than cluttering all of our assertions like this.

Copy link
Member Author

Choose a reason for hiding this comment

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

Looks like register_assert_rewrite("tests") will work - but you need to include it in conftest.py.

@freakboy3742 freakboy3742 requested a review from mhsmith December 14, 2022 07:55
Comment on lines 266 to 267
async def redraw(self):
"""Request a redraw of the app, waiting until that redraw has completed."""
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure about adding this to the public API, as it's unlikely to be useful outside of the Toga testbed. And as we mentioned the other day, whenever a property changes we should refresh things as necessary, just like the HTML DOM does. So any time an app needs to call refresh manually, that means there's a bug in Toga.

Instead, how about adding this as a method on the probe class? Then it could be implemented at either the window level or the widget level, depending how redrawing works on each platform.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure that we would want to force a refresh after every update - at least, not without also adding another mechanism to suspend those updates.

Consider the case where you're updating a whole lot of widgets in a single handler - say - a big HUD style GUI with 100 widgets. At present, you can update each of the 100 widgets, and they'll all get refreshed when the handler exits and the redraw loop happens. If we bake a redraw into every widget change, there's going to be 99 "in-progress" redraws before the final update and redraw. Aside from the potential for drawing the UI in an "incomplete" state, that's going to be a pretty heavy redraw load. GUI paint operations aren't cheap, and they're the most likely to be observed by users as a "performance problem", because they'll manifest as graphical glitches you can clearly see.

The only way this approach would work in practice is if we also added a "pause updates" context manager that instructs Toga to not do per-widget updates while in the graphical context - this sort of thing is reasonably common for graphical APIs, so it wouldn't be completely weird; but it means decorating your code with "pause" wrappers anywhere that you've got moderately complex GUI updates.

The alternative is what I've got here. By default, redraws happen "as soon as practical". While I agree that this is somewhat contrary to a beginners expectation, it's also something that is reasonably easy to explain; and I'd argue that in most cases, it's the behavior you actually want, because it is optimised for visual performance. However, if you need the GUI to update right now, await redraw() is an explicit way of doing the await asyncio.sleep(0.01) workaround. It's not something you need in the common case, but it's there if you do need it, and the default behaviour is semi-optimized.

Copy link
Member Author

Choose a reason for hiding this comment

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

Following discussion in person; an async redraw() coroutine won't actually be that useful in practice, as the most likely use case is someone wanting to update in the middle of a synchronous handler; and the real fix for that is to not use a synchronous handler. On that basis, I'll demote redraw() to being a probe method.

@freakboy3742
Copy link
Member Author

I've finally got to the bottom (or, at least, the side) of the iOS crash problem; it appears caused by the cleanup process of coverage. We'll need to tackle that as part of adding the iOS test backend, but for now, we can punt on it.

So - I think this is ready for another review pass. Some review notes:

  • It will need to be rebased onto/merged with Modify dependency pins and definition. #1731 (and that PR contains some elements of this one)
  • The colors demo was fairly badly broken by the get_widget_by_id change; I needed to use that app to test the fixes for color handling on macOS, so there's a bunch of changes there.
  • There should be 100% coverage for button and label widgets, and the docstrings for those widgets should be fully up to date.

@freakboy3742 freakboy3742 requested a review from mhsmith January 11, 2023 06:54
@freakboy3742
Copy link
Member Author

Looks like the iOS CI failure was one of the transient ones (😓). Passed on a re-run.

@mhsmith
Copy link
Member

mhsmith commented Jan 15, 2023

I'll do a full review of this PR on Monday.

@mhsmith
Copy link
Member

mhsmith commented Jan 16, 2023

It will need to be rebased onto/merged with #1731

I've now done this.

android/tests_backend/widgets/button.py Outdated Show resolved Hide resolved
testbed/tests/assertions.py Outdated Show resolved Hide resolved
Comment on lines +46 to +49
# 2. The main thread where coverage has been started dies before the this
# thread; as a result, the garbage collection on the tracer function
# (coverage.pytracer._trace():132) raises an IndexError because the data
# stack is empty.
Copy link
Member

@mhsmith mhsmith Jan 16, 2023

Choose a reason for hiding this comment

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

As discussed, this should probably be addressed in a separate PR. But here are some ideas for that PR:

What if we simply didn't let the main thread die? After the call to main_loop, put a wait on a condition to block the thread, and then notify that condition in the on_exit handler. Then the main thread will resume after the test suite finishes, and it can deal with stopping the coverage and printing the exit status.

Copy link
Member Author

Choose a reason for hiding this comment

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

I thought of (and tried) that, but it doesn't work. On both iOS and Android, we rely on the main Python code block exiting to start the native event loop. On iOS, the call to create UIApplicationMain() happens immediately after the Python main block exits; if you don't call that method, you don't get GUI updates. On Android, blocking in the main block effectively blocks onCreate() from returning, which effectively locks the app.

Copy link
Member

Choose a reason for hiding this comment

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

Oh right, of course.

Comment on lines 31 to 36
def assert_display_properties(self):
# If the button is tall (for any reason), it should have a different bezel style.
if self.native.frame.size.height > 50:
assert self.native.bezelStyle == NSBezelStyle.RegularSquare
else:
assert self.native.bezelStyle == NSBezelStyle.Rounded
Copy link
Member

Choose a reason for hiding this comment

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

I imagine we might need a method like this eventually, but maybe we don't need it here. What if ButtonProbe just overrides the height property so that it calls through to the superclass implementation and then runs this assertion? Then the tests don't need to be cluttered with all the assert_display_properties calls, and the other probe classes don't need all the empty implementations.

I did something similar with the Slider here: implementing one probe property in terms of two native properties, while asserting certain constraints on the combinations of values they can take.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't particularly like having a standalone assertion class either (especially given it will be empty on most implementations) - but having assertions on an attribute probe bugs me as well. I think it makes a certain amount of sense to maintain a separation between "things being measured" and "things being asserted".

That said, I think you may be right. Completely aside from the issue of having mostly empty implementations, for Button on Cocoa, we need to assert that when the button's height changes, the properties of the button also change. That obviously happens when a test changes the size of the button, but it also happens implicitly if a test changes the font - something that the current font change property tests doesn't check for.

So - as a sort of "general policy" statement, I guess it makes sense to say that if an implementation-specific property (like bezelStyle) is a direct consequence of an interface-level property (like height), we should include an assertion in the probe's attribute access.

If I'm reading the slider example correctly, the same is true there as well. There's an interface level probe property reporting the number of ticks, which returns None if the widget doesn't have ticks; but there's also a specific implementation requirement that if the widget hasn't been configured for tick marks, numberOfTickMarks must be 0. I'm not sure if this is because of some API issue where having numberOfTickMarks != 0 and allowsTickMarkValuesOnly == False causes a GUI issue, or it's just a case of keeping a clean house internally - but either way, it's a native level concern stemming from an observable interface property, and changes to the interface property should impact on the native rendition of that property.

Comment on lines 124 to 128
elif prop in (
"height",
"width",
):
self._applicator.set_size(self.width, self.height)
Copy link
Member

Choose a reason for hiding this comment

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

I assume this was added for the benefit of the Cocoa button bezel style. But doesn't this mean that the bezel will only respond to a button with an explicit height attribute, as opposed to one that's been automatically sized by the Pack layout algorithm? That seems inconsistent with the assertions in the Button probe.

Could we fix this by making the Cocoa Button override set_bounds instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

You're correct that this was added to deal with the bezel style.

The approach of adding set_size() was the result of me overthinking this a bit. The concern I was trying to avoid is that macOS buttons (and buttons generally) don't expand vertically to fill space - and as a result, on cocoa, we only need the bezel change when the height is set explicitly. Adding set_size() meant we could respond directly to setting the height of the widget, rather than implicitly responding whenever the bounds are set.

However, in practice, it won't actually change the observed behavior. Absent of a specific height value, the bounds height of a button will be the intrinsic height - the bounds of the button don't expand to fill the available space, even if the layout would allow it..

Comment on lines 34 to 38
if color is TRANSPARENT:
self.native.drawsBackground = False
else:
self.native.backgroundColor = native_color(color)
self.native.drawsBackground = True
Copy link
Member

Choose a reason for hiding this comment

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

If most widgets will need the same implementation of this method, wouldn't it make more sense to put it in the base class and override it where necessary?

Copy link
Member Author

Choose a reason for hiding this comment

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

This has come up in the past - It turns out supporting background color actually isn't the common case.

Cocoa currently has 22 widgets (excluding the base class); of those, only 8 support setting the background color (with PasswordInput inheriting TextInput's implementation), and one of those (Button) has a different implementation to the others. Of the remaining widgets, it's not clear if they should support background colors (ActivitySpinner? Divider? SplitContainer?) - and if they should, that the implementation will be the common implementation (Table? Tree?)

Given that, do we implement:

  1. A base class that provides a default implementation, overridden on subclasses that have a different implementation, or no support for background colors; or
  2. A base class with no implementation, and a duplicated implementation on the classes that need it.

The code currently does (2), on the basis that not supporting set_background_color is the common case; but as you've noted, it does leads to some code duplication. Maybe this is a case for a mixin or a utility method; to date it hasn't been enough of an issue to warrant the effort/complication.

Copy link
Member Author

Choose a reason for hiding this comment

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

... and now I do a review of the actual diff, and I realise that I was reversing the previous implementation.

Copy link
Member

Choose a reason for hiding this comment

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

For future reference: see related discussion above.

core/src/toga/widgets/base.py Outdated Show resolved Hide resolved
core/src/toga/widgets/base.py Outdated Show resolved Hide resolved
docs/reference/api/widgets/button.rst Outdated Show resolved Hide resolved
docs/reference/api/widgets/widget.rst Outdated Show resolved Hide resolved
cocoa/tests_backend/widgets/button.py Outdated Show resolved Hide resolved
@@ -10,7 +10,7 @@ class LabelProbe(SimpleProbe):

@property
def text(self):
return self.native.stringValue
return str(self.native.stringValue)
Copy link
Member

@mhsmith mhsmith Jan 19, 2023

Choose a reason for hiding this comment

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

Why is str necessary: what other type could it be? IAlso applies to Button.)

Copy link
Member Author

Choose a reason for hiding this comment

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

The type returned by Rubicon will be an NSString (or, strictly, an ObjCStrInstance wrapper of __NSCFString). which will mostly behave like a string for most common usage, but aren't completely compatible. There's a couple of reasons to not autoconvert to str (see the note here). However, to make sure that Objective C types don't leak into user space, the cocoa and iOS backends cast to str(); I figured the probe should do the same.

Copy link
Member

@mhsmith mhsmith Jan 20, 2023

Choose a reason for hiding this comment

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

Thanks: I don't see a clear explanation in the documentation of why it doesn't autoconvert, but I found beeware/rubicon-objc#101 and the linked Gitter discussion, which says it's to allow Python code to call NSString methods like sizeWithAttributes.

Comment on lines 34 to 38
if color is TRANSPARENT:
self.native.drawsBackground = False
else:
self.native.backgroundColor = native_color(color)
self.native.drawsBackground = True
Copy link
Member

Choose a reason for hiding this comment

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

For future reference: see related discussion above.

@freakboy3742
Copy link
Member Author

I think I've addressed all your feedback; we're now waiting on a Rubicon-objc release that includes beeware/rubicon-objc#246, and a related bump to the macOS and iOS dependency pin.

@freakboy3742 freakboy3742 merged commit 0803fcf into beeware:main Jan 23, 2023
@freakboy3742 freakboy3742 deleted the macos-probe branch January 23, 2023 00:24
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.

2 participants