Add role option to Switch, with checkbox and switch options - #4667
Add role option to Switch, with checkbox and switch options#4667760ceb3b9c0ba4872cadf3ce35a7a494 wants to merge 30 commits into
Conversation
freakboy3742
left a comment
There was a problem hiding this comment.
Thanks for the PR!
On first inspection, this is definitely on the right track; there's a couple of details that stand out on an initial review:
-
You've implemented both Switch And Checkbox as a single class... and then almost every method is implemented as
if switch:... else: .... To me, that's calling out for an abstract base class with two concrete implementations. -
There's no testbed tests - as a result, there's now coverage gaps for macOS (which is showing in CI).
-
We need to be careful about backwards compatibility. If we're deprecating
Switch, it should be raising a deprecating warning on use; and the existing examples that use Switch should be updated.However, on reflection, I'm not necessarily sure that's what we should be doing. As @mhsmith flagged in the original discussion, changing the widget will be a lot of code churn, for not a lot of real gain - Toggle isn't an inherently better name; on the other hand, Switch:
- is an accurate name on a lot of platforms,
- is broadly HIG-appropriate when used as a single standalone widget
- isn't wildly off base as a name for a checkbox
-
You've missed the other "possibly" option - the major/minor role.
typeis always a bit of a weird argument to use in Python (because it's a builtin type);rolehas the potential for use in other contexts (e.g, toga.Button(role="Cancel"))
| def create(self): | ||
| if self.toggle_type == ToggleType.CHECKBOX: |
There was a problem hiding this comment.
To me, this says it should be implemented as a subclass.
There was a problem hiding this comment.
i switched to an abstract base class, with a function to initialize the correct subclass for the switch role. does that seem okay?
|
Thanks for the contribution! A couple of quick thoughts about the process of making the change. The first is that while you're still working on this, you might want to use a draft pull request until you think the code is ready for review. The second thought is that if we remove the existing Edit: looks like @freakboy3742 beat me to it. |
good point, changed it.
That's not what this does! Switch still works. |
i'm totally fine with the name remaining Switch. i'll defer to you on that one :) In which case - let's keep it as Switch. |
Apologies - I missed that |
34e51e9 to
c4f39c1
Compare
johnzhou721
left a comment
There was a problem hiding this comment.
I'm not a core team member, but here are some concrete things that I personally think may be worth discussing. Below is all my personal opinion; take with a grain of salt.
Overall the implementation looks very clean though, I liked all the tricks you've used to abstract things.
| def Switch(interface): | ||
| actual_role = determine_actual_toggle_role(interface._role) | ||
| if actual_role == SwitchRole.SWITCH: | ||
| return SwitchToggle(interface) | ||
| return CheckboxToggle(interface) |
There was a problem hiding this comment.
I liked how pragmatic this is in not needing to change the other backends, but using a function disguised as a class is sort of uncommon... flagging this as such, but how or whether to refactor this is ultimately the core team's call.
(Same for SwitchProbe factory method)
There was a problem hiding this comment.
yes, I agree, and I felt a little weird doing it , but I wasn't really sure how else to make this work, this being a "class" that conditionally uses one of two implementations based on a parameter. another option is to bring the distinction out of the cocoa backend and make core aware of switch versus checkbox stuff, but (as you note) that would require every current backend to be altered. SwitchProbe also working this way mirrors this decision.
There was a problem hiding this comment.
also, on some backends (such as I think UIKit with Mac Catalyst) checkbox/switch appearance is a parameter rather than a separate widget, which might suggest that it makes sense to keep the class separation out of core, as some backends may not want different classes here.
There was a problem hiding this comment.
Hmm... seems like you have a good point here. Some backends may not want to enforce this separation of different classes.
I'm not sure how to refactor this either, so let's address this only if the core team thinks it's a concern.
There was a problem hiding this comment.
I can think of three possible approaches that remove the need for the "class but a function":
- Register 5 different backend points. At present, we've got 1-1 correspondence between the frontend widgets and backend widgets. However, there's no reason why that has to be the case - we could define registration points of:
Switch = "toga_cocoa.widgets.switch:Switch"
Checkbox = "toga_cocoa.widgets.switch:Checkbox"
Switch_Major = "toga_cocoa.widgets.switch:Switch"
Switch_Minor = "toga_cocoa.widgets.switch:Checkbox"
Switch_Auto = "toga_cocoa.widgets.switch:Checkbox"
If the backend doesn't define Checkbox, Switch_Major, Switch_Minor, or Switch_Auto, the widget falls back to Switch. That doesn't require any changes to other backends.
In the Catalyst case where it's the same widget but with an option, it can either be one class whose construction is based on the role argument extracted from the interface; or two subclasses where the only difference is a single argument at construction.
- Expose
determine_actual_toggle_roleas part of the interface (although I'd probably use a name likeswitch_for_role:
switch_for_role = "toga_cocoa.widgets.switch:switch_for_role"
That way, we wouldn't need Switch_Major, Switch_Minor etc; you call the backend function to determine which switch widget is needed; if the function isn't defined, fall back to Switch.
- Expose Switch and Checkbox as explicit backend widgets, but have class properties on those classes that define the roles they can be used for.
class Switch(Toggle):
roles = {SwitchRole.MAJOR}
...
class CheckBox(Toggle):
roles = {SwitchRole.MINOR, SwitchRole.AUTO}
This has the advantage that the backend explicitly defines only widgets that exist; but provides enough metadata to determine when that widget is appropriate. The role lookup can be cached on the core class.
Of the three options, I think option 3 appeals to me the most. The one notable downside is that if a backend comes up with some new format for "switch" content, it's not something that backend can automatically accommodate. But we'd need to add a new constant value to allow that anyway; and new widgets don't get added that often.
There was a problem hiding this comment.
thanks for this response. just now I went with option 2 but i'm open to changing it again. it feels very clean, and i guess the core being neutral to SwitchRoles just appeals to me more than making it even a little bit explicit in the class names.
| These override matching properties on the `style` argument. | ||
| :param kwargs: Initial style properties. | ||
| """ | ||
| self._role = role |
There was a problem hiding this comment.
Raising for discussion: Should there be a public readonly property exposing the role?
There was a problem hiding this comment.
If we're capturing the parameter, we might as well expose it as a property.
| if actual_role == SwitchRole.SWITCH: | ||
| return SwitchToggle(interface) | ||
| return CheckboxToggle(interface) |
There was a problem hiding this comment.
But also, this can be simplified a bit using inline if syntax (return SwitchToggle(interface) if actual_role == SwitchRole.SWITCH else CheckboxToggle(interface))
There was a problem hiding this comment.
that's certainly shorter, but I don't know if it's simpler!
There was a problem hiding this comment.
My personal opinion is that inline if/else is easier to read, but I think waiting for a core team member to answer here is the better option here.
There was a problem hiding this comment.
Not sure if showcasing both forms of toggles are needed (let's wait for core team response), but if it is, the screenshot example app needs to be updated to match the layout you've used here, so that those images can be reproduced later.
There was a problem hiding this comment.
ohh i see, i was not aware of the screenshot app!
There was a problem hiding this comment.
Yeah, I think we should leave it for now... there's advantages to showing one or both forms in the screenshot, so my personal inclination would be to wait for a core team member to respond at their convenienc here before we update the example app to match our final form of showcase.
There was a problem hiding this comment.
We should definitely update the example app before we land this - providing examples of each role in the screenshot.
There was a problem hiding this comment.
i reverted the screenshot change and updated the switch_demo example to show off auto/major/minor roles.
Co-authored-by: John <johnzhou721@gmail.com>
Co-authored-by: John <johnzhou721@gmail.com>
johnzhou721
left a comment
There was a problem hiding this comment.
I'm personally good with how this PR looks now, although I'm not affiliated with BeeWare. I did flag out some small things inline for the last pass, but these are all minor concerns. I'm personally not overly concerned about them, but I'm not part of the core team so it's at their discretion if they're actual concerns.
Also: From my read of the contribution guide, it's usually not customary to mark other reviewer's conversations as Resolved (https://toga.beeware.org/en/stable/how-to/contribute/next/pr-review/#work-through-requested-changes) here. Personally, all the comments you marked as resolved were indeed properly worked on (good work on this front!), so there's no issue this time, but since I also missed this guidance when I first started contributing, I just wanted to point you to the project's conventions.
|
thank you for the reminder, clearly I had missed that!! |
freakboy3742
left a comment
There was a problem hiding this comment.
Nice work! The tests and widget implemenations all look good; the only part left is finessing the specifics of the "which widget?" logic, and a little more in the documentation and examples.
| return role | ||
|
|
||
|
|
||
| class BaseToggle(Widget, ABC): |
There was a problem hiding this comment.
If we're not using Toggle as a public name, we can use Toggle as the base class, and then Switch and Checkbox as the concrete classes.
There was a problem hiding this comment.
renamed!
| def Switch(interface): | ||
| actual_role = determine_actual_toggle_role(interface._role) | ||
| if actual_role == SwitchRole.SWITCH: | ||
| return SwitchToggle(interface) | ||
| return CheckboxToggle(interface) |
There was a problem hiding this comment.
I can think of three possible approaches that remove the need for the "class but a function":
- Register 5 different backend points. At present, we've got 1-1 correspondence between the frontend widgets and backend widgets. However, there's no reason why that has to be the case - we could define registration points of:
Switch = "toga_cocoa.widgets.switch:Switch"
Checkbox = "toga_cocoa.widgets.switch:Checkbox"
Switch_Major = "toga_cocoa.widgets.switch:Switch"
Switch_Minor = "toga_cocoa.widgets.switch:Checkbox"
Switch_Auto = "toga_cocoa.widgets.switch:Checkbox"
If the backend doesn't define Checkbox, Switch_Major, Switch_Minor, or Switch_Auto, the widget falls back to Switch. That doesn't require any changes to other backends.
In the Catalyst case where it's the same widget but with an option, it can either be one class whose construction is based on the role argument extracted from the interface; or two subclasses where the only difference is a single argument at construction.
- Expose
determine_actual_toggle_roleas part of the interface (although I'd probably use a name likeswitch_for_role:
switch_for_role = "toga_cocoa.widgets.switch:switch_for_role"
That way, we wouldn't need Switch_Major, Switch_Minor etc; you call the backend function to determine which switch widget is needed; if the function isn't defined, fall back to Switch.
- Expose Switch and Checkbox as explicit backend widgets, but have class properties on those classes that define the roles they can be used for.
class Switch(Toggle):
roles = {SwitchRole.MAJOR}
...
class CheckBox(Toggle):
roles = {SwitchRole.MINOR, SwitchRole.AUTO}
This has the advantage that the backend explicitly defines only widgets that exist; but provides enough metadata to determine when that widget is appropriate. The role lookup can be cached on the core class.
Of the three options, I think option 3 appeals to me the most. The one notable downside is that if a backend comes up with some new format for "switch" content, it's not something that backend can automatically accommodate. But we'd need to add a new constant value to allow that anyway; and new widgets don't get added that often.
| return self.impl.label_native.font | ||
|
|
||
|
|
||
| # noinspection PyPep8Naming |
There was a problem hiding this comment.
What is this annotation for?
There was a problem hiding this comment.
it's for the capitalized function name, although i realize it might not be required as i omitted it in the other function and nothing complained.
| xfail("Can't get/set the text color of a switch on macOS") | ||
|
|
||
|
|
||
| class SwitchSwitchProbe(CheckboxSwitchProbe): |
There was a problem hiding this comment.
SwitchSwitch is a little... convoluted. I'd be inclined to name these _CheckboxProbe and _SwitchProbe.
There was a problem hiding this comment.
renamed!
| These override matching properties on the `style` argument. | ||
| :param kwargs: Initial style properties. | ||
| """ | ||
| self._role = role |
There was a problem hiding this comment.
If we're capturing the parameter, we might as well expose it as a property.
There was a problem hiding this comment.
We should definitely update the example app before we land this - providing examples of each role in the screenshot.
|
|
||
| - The button and the label are considered a single widget for layout purposes. | ||
| - The visual appearance of a Switch is not guaranteed. On some platforms, it will render as a checkbox. On others, it will render as a physical "switch" whose position (and color) indicates if the switch is active. When rendered as a checkbox, the label will appear to the right of the checkbox. When rendered as a switch, the label will be left-aligned, and the switch will be right-aligned. | ||
| - The `role` parameter, which determines the visual appearance of a switch, is currently only supported on macOS. On other platforms, the appearance of a Switch is not guaranteed. On some platforms, it will render as a checkbox. On others, it will render as a physical "switch" whose position (and color) indicates if the switch is active. When rendered as a checkbox, the label will appear to the right of the checkbox. When rendered as a switch, the label will be left-aligned, and the switch will be right-aligned. |
There was a problem hiding this comment.
Historically, a platform note was appropriate to explain platform discrepancies; I think this now needs to graduate to full documentation, giving a full explanation of what the "role" is for, and advising the use of MAJOR and MINOR over "just a switch".
There was a problem hiding this comment.
i just expanded on this in the docs, which should make things more clear.
Co-authored-by: Russell Keith-Magee <russell@keith-magee.com>
|
ok, the last round of comments should all be addressed now :> |
this PR aims to get closer to the end-state described in #2225 (comment) by providing a
roleparameter to make a Switch display like a checkbox, a switch, or automatically based on platform defaults.rename Switch to Toggle (with backwards compatibility)roleparameter and aSwitchRoleenum to choose appearanceSwitchRoleslater on: implement all
SwitchRoles for other backends (when possible)PR Checklist: