Skip to content

Conversation

@ignaciocabeza
Copy link
Contributor

Signed-off-by: Ignacio Cabeza [email protected]

Added method to enable or disable an option of the OptionContainer for Cocoa
This is a WIP.

image

import toga
from toga.constants import COLUMN, ROW
from toga.style import Pack

class ExampleApp(toga.App):
        
    def on_enable(self, button):
        self.optioncontainer.set_option_enabled(1, True)

    def on_disable(self, button):
        self.optioncontainer.set_option_enabled(1, False)

    def startup(self):
        self.main_window = toga.MainWindow(title="Test Window")
        self.button = toga.Button('enable tab 1', on_press=self.on_enable)
        self.button2 = toga.Button('disable tab 1', on_press=self.on_disable)

        box1 = toga.Box()
        box2 = toga.Box()
        self.optioncontainer = toga.OptionContainer()
        self.optioncontainer.add('Option1', box1)
        self.optioncontainer.add('Option2', box2)
        
        controls = [
            self.button,
            self.button2,
            self.optioncontainer
        ]

        self.box = toga.Box(children=controls, style=Pack(direction=COLUMN, flex=1))
        self.main_window.content = self.box
        self.main_window.show()

def main():
    return ExampleApp('Test', 'org.beeware.widgets.testing')

if __name__ == '__main__':
    app = main()
    app.main_loop()

I think a better way would be to have a OptionItem class and store all instance of OptionItem inside of an OptionContainer and use it in this way:

...
self.optioncontainer.add('Option1', box1)
...

self.optioncontanier.get(1).enabled = False
self.optioncontanier.get(1).label = "I want to change the label

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.

Definitely a good suggestion for an API extension.

I agree with your suggested "better" API though - we need a wrapper object at the interface layer that can serve as a proxy for issuing commands on the implementation backend.

However, instead of using get(), I'd suggest overloading the __getitem__ protocol, and then defining property getters and setters for the features of interest:

table.option[3].enabled = True
table.option[0].title = 'Something new'

This would also allow (potentially) for lookup by name, as well as by index.

However, this API only needs to exist at the interface layer. The underlying implementation (cocoa, etc) can expose an interface similar to the one you've described here, where the API can require providing an index on set_option_enabled etc methods.

@ignaciocabeza
Copy link
Contributor Author

I was reading the PRs and should I wait until #821 before start?

@freakboy3742
Copy link
Member

@ignaciocabeza In an ideal world, I agree it would be good to avoid working on this until #821 lands; however, FLOSS isn't an ideal world :-) Since everyone is a volunteer, it's difficult to establish when anyone will have the time to finish a patch, unless they're actively working on it (by which I mean we're seeing near daily updates).

I'll drop a comment on #821 mentioning that you're looking at this; but otherwise, I'd say you're free to proceed, and whoever lands second will have some merging work to do.

@ignaciocabeza
Copy link
Contributor Author

ignaciocabeza commented Apr 4, 2020

Hi, I did some implementation of what we talked. I don't know if exactly as you described (my programming fundamentals are a little bit rusty).

Edit: and I added an example

@enabled.setter
def enabled(self, value):
self._enabled = value
self._container.interface._impl.set_option_enabled(self._index, value)
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'm not sure if this is a correct use of toga or I'm breaking some toga design principle

Copy link
Member

Choose a reason for hiding this comment

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

The general idea of "invoke a method on the impl" is exactly the right idea.

The only criticism I'd have would be around how you get to the "_impl" in the first place.

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 really good stuff - definitely heading in the right direction. I know there's a lot of comments inline, but most of them are relatively simple changes, mostly consequences of exactly how the OptionItem is being constructed. The general approach you've taken is dead on.

@enabled.setter
def enabled(self, value):
self._enabled = value
self._container.interface._impl.set_option_enabled(self._index, value)
Copy link
Member

Choose a reason for hiding this comment

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

The general idea of "invoke a method on the impl" is exactly the right idea.

The only criticism I'd have would be around how you get to the "_impl" in the first place.


class OptionItem:
def __init__(self, container, label, widget, enabled):
self._container = container
Copy link
Member

Choose a reason for hiding this comment

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

The name "container" isn't quite right here. The actual object will be an OptionList, which will be owned by an OptionContainer.

That said, having a link to the list isn't especially helpful - you actually want a reference to the optioncontainer interface.

This variable should also be "None by default", and only assigned when the item is assigned to an optionList. Same goes for self._index.

class OptionItem:
def __init__(self, container, label, widget, enabled):
self._container = container
self._index = len(container)
Copy link
Member

Choose a reason for hiding this comment

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

This should be a property of assignment, not construction. If you were to insert a tab at position N, "index" will only be correct if N is the last position in the list.

self._index = len(container)
self._label = label
self._widget = widget
self._enabled = enabled
Copy link
Member

Choose a reason for hiding this comment

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

Rather than having a duplicate of this data, it would be preferable to have it read directly from the actual widget. Is it possible to read the enabled/disable state from the widget itself?

Copy link
Member

Choose a reason for hiding this comment

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

This looks like it's still an issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The problem I'm facing here is that I'm not saving OptionItem Widget _impl anywhere. This OptionItem class is only saving the widget (self._widget) inside the OptionItem.

So, to access to label and enabled values I have to create some methods in the implementation layer like for example get_label and is_enabled and at the moment I only have OptionContainer implementation class to make this methods.

But rather than create that methods in the OptionContainer, It would be better have an OptionItem in the implementation layer, to put all the specific OptionItem functionality detached of the OptionContainer. Do you think this is the best way to do it?

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 I follow. The OptionItem has a link to the OptionContainer interface, from which you can get to the OptionContainer impl; If the impl layer has an set_option_enabled(index, state) and is_option_enabled(index), that would allow the interface to directly access the state of the widget by providing the context that is needed (i.e., the index/label) to answer the request.

The key insight here is that the API at the impl layer is purely functional - it's whatever lets us get the job done. It doesn't need to match the public interface-layer API. We can have methods on the impl layer that let us achieve functional goals, but don't have any directly corresponding method on the interface layer. That's one of the benefits of a layered API - we can expose a completely clean API to the end user, but internally use whatever API makes sense to bridge the differences between platforms.

We're already doing a similar thing with Tree/Table widgets. The public API for the Tree/Table is based around data sources; however, the impl layer talks about adding rows, refreshing rows, etc.

Does that make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The key insight here is that the API at the impl layer is purely functional
I based on this to make changes, I wasn't sure about what do or not to do in impl layer but now I think it's clear. Thanks!

opt.enabled for opt in self._content
if opt != self._content[index]
]
)
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 I follow what this logic is for. You can't remove a tab if any of the tabs are disabled? That doesn't seem to make any sense to me? At the very least, an explanatory comment is needed.

Copy link
Contributor Author

@ignaciocabeza ignaciocabeza Apr 15, 2020

Choose a reason for hiding this comment

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

If we don't have this check and we delete option3 in this scenario:

  • option1(disabled)
  • option2(disabled)
  • option3(enabled)

Remove will work OK but option2 will be focused and disabled at the same time which I think is worse than don't allow remove it. This behaviour at least is happening in macos.

Copy link
Member

Choose a reason for hiding this comment

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

Ah - that makes sense, but it's not exactly intuitive.

I'm not completely convinced "disabling the tab you're on" is as bad as you suggest; but if we are going to support this edge case, I'd argue it would make more sense to force focus onto the first non-disabled tab after the one you've selected (wrapping around to the first tab if that isn't an option). That would also mean that if there isn't at least one enabled tab to land on, you can't disable a tab, and attempting to do so should raise an error.

As it happens, I was able to get into this state via a different path: in your demo app, if you delete all the tabs but one, and press the "toggle first button", you end up with a "disabled but enabled" tab.

In that state, you can also press the delete tab button, and it does nothing. That's effectively the "nowhere to land" case I described previously.

@ignaciocabeza
Copy link
Contributor Author

Hi @freakboy3742. I did some changes according to your recommendations

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.

A couple more tweaks, mostly around the remove API. It's looking good, though!

self._index = len(container)
self._label = label
self._widget = widget
self._enabled = enabled
Copy link
Member

Choose a reason for hiding this comment

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

This looks like it's still an issue.

opt.enabled for opt in self._content
if opt != self._content[index]
]
)
Copy link
Member

Choose a reason for hiding this comment

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

Ah - that makes sense, but it's not exactly intuitive.

I'm not completely convinced "disabling the tab you're on" is as bad as you suggest; but if we are going to support this edge case, I'd argue it would make more sense to force focus onto the first non-disabled tab after the one you've selected (wrapping around to the first tab if that isn't an option). That would also mean that if there isn't at least one enabled tab to land on, you can't disable a tab, and attempting to do so should raise an error.

As it happens, I was able to get into this state via a different path: in your demo app, if you delete all the tabs but one, and press the "toggle first button", you end up with a "disabled but enabled" tab.

In that state, you can also press the delete tab button, and it does nothing. That's effectively the "nowhere to land" case I described previously.

Copy link
Contributor Author

@ignaciocabeza ignaciocabeza left a comment

Choose a reason for hiding this comment

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

I made some new changes. I re made optioncontainer example and I changed some issues you mentioned in the previous review.

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.

We're definitely getting closer; but we've still got some edge cases around enable/disable/remove logic.

In the last iteration I reviewed, you had some logic around validating whether a tab removal was legal; that logic seems to have been removed. I think that logic was necessary; it just had some edge cases that weren't quite right.

@ignaciocabeza
Copy link
Contributor Author

Hi!,

I covered 3 edge cases in cocoa implementation:

  1. Don't allow remove if there is only one tab left
  2. Don't allow disable if there is only one tab left
  3. Don't allow disable tab if tab is selected
    (Please review english messages because english is not my first lang)

And I renamed methods for consistency.

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.

We're getting there...!

raise Exception('Disable selected Option is not allowed')
raise OptionException('Selected Option cannot be disabled')

if self.native.numberOfTabViewItem == 1:
Copy link
Member

Choose a reason for hiding this comment

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

This took me a moment to understand; it relies on the implied knowledge that if there's only one item, it must be enabled. While this is absolutely correct, it's worth being explicit about this by adding an "if not enabled" - either in the code, or in a code comment explaining the edge case.

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 also once more edge case, which is the more general case of "only 1 tab" - the case where you're disabling the last enabled tab - (i.e., tab A B and C all exist and are disabled, tab D exists and is selected and enabled; you can't disable D).

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 this case is covered by raise OptionException('Currently selected option cannot be disabled'). if you disable A,B,C then D has to be selected. I tried in example app and exception is raised.

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.

We're getting closer, but I'm still finding edge cases in my testing, mostly around deleting tabs that are selected, and deleting the last tab.

These instructions might be a little confusing because of the discrepancy between the tab name "option 1" and it's position "0"; I think it would be worth modifying the example so that the name of the tab and the position in the pulldown is the same (it doesn't really matter which way you fall, as long as the naming is consistent).

Case 1

  • Start app
  • Mark index 1 disabled
  • Mark index 2 disabled
  • Remove option 0
  • App doesn't crash; Option 2 and 3 remain, in a disabled state; box1 content remains

Case 2

  • Select option3
  • Disable index 1
  • Delete index 2
  • Option 1 is marked as selected; but the widget content is still box 3.

@ignaciocabeza
Copy link
Contributor Author

We're getting closer, but I'm still finding edge cases in my testing, mostly around deleting tabs that are selected, and deleting the last tab.

These instructions might be a little confusing because of the discrepancy between the tab name "option 1" and it's position "0"; I think it would be worth modifying the example so that the name of the tab and the position in the pulldown is the same (it doesn't really matter which way you fall, as long as the naming is consistent).

Case 1

  • Start app

  • Mark index 1 disabled

  • Mark index 2 disabled

  • Remove option 0

  • App doesn't crash; Option 2 and 3 remain, in a disabled state; box1 content remains

Case 2

  • Select option3

  • Disable index 1

  • Delete index 2

  • Option 1 is marked as selected; but the widget content is still box 3.

I resolved case Case 2 no letting delete index 2 if it's selected.

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.

Thanks for those updates. Thinking about it some more, I think we've been over-thinking things. If we make any attempt to disable or remove the currently selected tab, all the edge cases we've previously described disappear. The only cost is the behavior where deleting the current tab would cause a different tab to be selected; but it's not immediately clear to me that this is universally desirable behavior anyway.

I also noticed that in order to be useful, the exception needs to be in the interface layer.

I've made those changes, along with a couple of other small cosmetic fixes.

Thanks for the contribution - another great addition to the Toga API!

@freakboy3742 freakboy3742 merged commit 803bcf4 into beeware:master May 16, 2020
@ignaciocabeza
Copy link
Contributor Author

Thanks for merging!. I tried the example after your changes and you're right about over-thinking. Thanks a lot for the 2 month feedback.

@mhsmith mhsmith mentioned this pull request Aug 9, 2023
11 tasks
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