diff --git a/changes/1996.feature.rst b/changes/1996.feature.rst new file mode 100644 index 0000000000..405bb99826 --- /dev/null +++ b/changes/1996.feature.rst @@ -0,0 +1 @@ +The OptionContainer widget now has 100% test coverage, and complete API documentation. diff --git a/changes/1996.removal.1.rst b/changes/1996.removal.1.rst new file mode 100644 index 0000000000..119d0bd069 --- /dev/null +++ b/changes/1996.removal.1.rst @@ -0,0 +1 @@ +The ability to increment and decrement the current OptionContainer tab was removed. Instead of `container.current_tab += 1`, use `container.current_tab = container.current_tab.index + 1` diff --git a/changes/1996.removal.2.rst b/changes/1996.removal.2.rst new file mode 100644 index 0000000000..7b1b2a7c64 --- /dev/null +++ b/changes/1996.removal.2.rst @@ -0,0 +1 @@ +``OptionContainer.add()``, ``OptionContainer.remove()`` and ``OptionContainer.insert()`` have been removed, due to being ambiguous with base widget methods of the same name. Use the ``OptionContainer.content.append()``, ``OptionContainer.content.remove()`` and ``OptionContainer.content.insert()`` APIs instead. diff --git a/changes/1996.removal.3.rst b/changes/1996.removal.3.rst new file mode 100644 index 0000000000..cd32b910ab --- /dev/null +++ b/changes/1996.removal.3.rst @@ -0,0 +1 @@ +The ``on_select`` handler for OptionContainer no longer receives the ``option`` argument providing the selected tab. Use ``current_tab`` to obtain the currently selected tab. diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index 6b21b3eee6..73d669e44c 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -1,83 +1,94 @@ -from rubicon.objc import objc_method +import warnings + +from rubicon.objc import SEL, objc_method from travertino.size import at_least -from toga_cocoa.container import Container -from toga_cocoa.libs import NSObject, NSTabView, NSTabViewItem +from toga_cocoa.container import Container, MinimumContainer +from toga_cocoa.libs import NSTabView, NSTabViewItem from ..libs import objc_property from .base import Widget -class TogaTabViewDelegate(NSObject): +class TogaTabView(NSTabView): interface = objc_property(object, weak=True) impl = objc_property(object, weak=True) + @objc_method + def tabView_shouldSelectTabViewItem_(self, view, item) -> bool: + return view.indexOfTabViewItem(item) not in self.impl._disabled_tabs + @objc_method def tabView_didSelectTabViewItem_(self, view, item) -> None: - # If the widget is part of a visible layout, and a resize event has - # occurred while the tab wasn't visible, the layout of *this* tab won't - # reflect the new available size. Refresh the layout. - if self.interface.window: - self.interface.refresh() + # Refresh the layout of the newly selected tab. + index = view.indexOfTabViewItem(view.selectedTabViewItem) + container = self.impl.sub_containers[index] + container.content.interface.refresh() - # Trigger any selection handler - if self.interface.on_select: - index = view.indexOfTabViewItem(view.selectedTabViewItem) - self.interface.on_select( - self.interface, option=self.interface.content[index] - ) + # Notify of the change in selection. + self.interface.on_select(None) + + @objc_method + def refreshContent(self) -> None: + # Refresh all the subcontainer layouts + for container in self.impl.sub_containers: + container.content.interface.refresh() class OptionContainer(Widget): def create(self): - self.native = NSTabView.alloc().init() - self.delegate = TogaTabViewDelegate.alloc().init() - self.delegate.interface = self.interface - self.delegate.impl = self - self.native.delegate = self.delegate + self.native = TogaTabView.alloc().init() + self.native.interface = self.interface + self.native.impl = self + self.native.delegate = self.native # Cocoa doesn't provide an explicit (public) API for tracking # tab enabled/disabled status; it's handled by the delegate returning # if a specific tab should be enabled/disabled. Keep the set of # currently disabled tabs for reference purposes. self._disabled_tabs = set() + self.sub_containers = [] # Add the layout constraints self.add_constraints() - def add_content(self, index, text, widget): - """Adds a new option to the option container. + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) - Args: - index: The index in the tab list where the tab should be added. - text (str): The text for the option container - widget: The widget or widget tree that belongs to the text. - """ - container = Container(content=widget) + # Setting the bounds changes the constraints, but that doesn't mean + # the constraints have been fully applied. Schedule a refresh to be done + # as soon as possible in the future + self.native.performSelector( + SEL("refreshContent"), withObject=None, afterDelay=0 + ) + def add_content(self, index, text, widget): + # Establish the minimum layout + widget.interface.style.layout(widget.interface, MinimumContainer()) + min_width = widget.interface.layout.width + min_height = widget.interface.layout.height + + # Create the container for the widget + container = Container() + container.content = widget + container.min_width = min_width + container.min_height = min_height + self.sub_containers.insert(index, container) + + # Create a NSTabViewItem for the content item = NSTabViewItem.alloc().init() item.label = text - # Turn the autoresizing mask on the widget widget - # into constraints. This makes the widget fill the - # available space inside the OptionContainer. - container.native.translatesAutoresizingMaskIntoConstraints = True - item.view = container.native self.native.insertTabViewItem(item, atIndex=index) def remove_content(self, index): tabview = self.native.tabViewItemAtIndex(index) - if tabview == self.native.selectedTabViewItem: - # Don't allow removal of a selected tab - raise self.interface.OptionException( - "Currently selected option cannot be removed" - ) - self.native.removeTabViewItem(tabview) - def set_on_select(self, handler): - pass + sub_container = self.sub_containers[index] + sub_container.content = None + del self.sub_containers[index] def set_option_enabled(self, index, enabled): tabview = self.native.tabViewItemAtIndex(index) @@ -85,16 +96,22 @@ def set_option_enabled(self, index, enabled): try: self._disabled_tabs.remove(index) except KeyError: + # Enabling a tab that wasn't previously disabled pass else: - if tabview == self.native.selectedTabViewItem: - # Don't allow disable a selected tab - raise self.interface.OptionException( - "Currently selected option cannot be disabled" - ) - self._disabled_tabs.add(index) - tabview._setTabEnabled(enabled) + + # This is an undocumented method, but it disables the button for the item. As an + # extra safety mechanism, the delegate will prevent the item from being selected + # by returning False for tabView:shouldSelectTabViewItem: if the item is in the + # disabled tab set. We catch the AttributeError and raise a warning in case the + # private method is ever fully deprecated; if this happens, the tab still won't + # be selectable (because of the delegate), but it won't be *visually* disabled, + # the code won't crash. + try: + tabview._setTabEnabled(enabled) + except AttributeError: # pragma: no cover + warnings.warn("Private Cocoa method _setTabEnabled: has been removed!") def is_option_enabled(self, index): return index not in self._disabled_tabs @@ -114,5 +131,13 @@ def set_current_tab_index(self, current_tab_index): self.native.selectTabViewItemAtIndex(current_tab_index) def rehint(self): - self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) - self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) + # The optionContainer must be at least the size of it's largest content, + # with a hard minimum to prevent absurdly small optioncontainers. + min_width = self.interface._MIN_WIDTH + min_height = self.interface._MIN_HEIGHT + for sub_container in self.sub_containers: + min_width = max(min_width, sub_container.min_width) + min_height = max(min_height, sub_container.min_height) + + self.interface.intrinsic.width = at_least(min_width) + self.interface.intrinsic.height = at_least(min_height) diff --git a/cocoa/tests_backend/widgets/optioncontainer.py b/cocoa/tests_backend/widgets/optioncontainer.py new file mode 100644 index 0000000000..79c67b05b6 --- /dev/null +++ b/cocoa/tests_backend/widgets/optioncontainer.py @@ -0,0 +1,49 @@ +from rubicon.objc import SEL, send_message + +from toga_cocoa.libs import NSTabView + +from .base import SimpleProbe + + +class OptionContainerProbe(SimpleProbe): + native_class = NSTabView + disabled_tab_selectable = False + + # 2023-06-20: This makes no sense, but here we are. If you render an NSTabView with + # a size constraint of (300, 200), and then ask for the frame size of the native + # widget, you get (314, 216). + # + # If you draw the widget at the origin of a window, the widget reports a frame + # origin of (-7, -6). + # + # If you draw an NSTabView the full size of a 640x480 window, the box containing the + # widget is 640x452, but the widget reports a frame of 654x458 @ (-7, -6). + # + # If you set the NSTabView to be 300x200, then draw a 300 px box below and a 200px + # box beside the NSTabView to act as rulers, the rulers are the same size as the + # NSTabView. + # + # I can't find any way to reverse engineer the magic left=7, right=7, top=6, + # bottom=10 offsets from other properties of the NSTabView. So, we'll hard code them + # and hope for the best. + LEFT_OFFSET = 7 + RIGHT_OFFSET = 7 + TOP_OFFSET = 6 + BOTTOM_OFFSET = 10 + + @property + def width(self): + return self.native.frame.size.width - self.LEFT_OFFSET - self.RIGHT_OFFSET + + @property + def height(self): + return self.native.frame.size.height - self.TOP_OFFSET - self.BOTTOM_OFFSET + + def select_tab(self, index): + self.native.selectTabViewItemAtIndex(index) + + def tab_enabled(self, index): + # _isTabEnabled() is a hidden method, so the naming messes with Rubicon's + # property lookup mechanism. Invoke it by passing the message directly. + item = self.native.tabViewItemAtIndex(index) + return send_message(item, SEL("_isTabEnabled"), restype=bool, argtypes=[]) diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index 1de3c5fc27..23e3405735 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -266,16 +266,11 @@ def refresh(self) -> None: # defer the refresh call to the root node. self._root.refresh() else: - self.refresh_sublayouts() # We can't compute a layout until we have a viewport if self._impl.viewport: super().refresh(self._impl.viewport) self._impl.viewport.refreshed() - def refresh_sublayouts(self) -> None: - for child in self.children: - child.refresh_sublayouts() - def focus(self) -> None: """Give this widget the input focus. diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 499f956f02..0344f9e1f5 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -1,109 +1,60 @@ -import warnings +from __future__ import annotations from toga.handlers import wrapped_handler from .base import Widget -# BACKWARDS COMPATIBILITY: a token object that can be used to differentiate -# between an explicitly provided ``None``, and an unspecified value falling -# back to a default. -NOT_PROVIDED = object() +class OptionItem: + """A tab of content in an OptionContainer.""" -class BaseOptionItem: - def __init__(self, interface): + def __init__(self, interface: OptionContainer, widget, index): self._interface = interface + self._content = widget + self._index = index + + widget.app = interface.app + widget.window = interface.window @property - def enabled(self): + def enabled(self) -> bool: + "Is the panel of content available for selection?" return self._interface._impl.is_option_enabled(self.index) @enabled.setter - def enabled(self, enabled): - self._interface._impl.set_option_enabled(self.index, enabled) + def enabled(self, value): + enable = bool(value) + if not enable and self.index == self._interface._impl.get_current_tab_index(): + raise ValueError("The currently selected tab cannot be disabled.") + + self._interface._impl.set_option_enabled(self.index, enable) @property - def text(self): + def text(self) -> str: + "The label for the tab of content." return self._interface._impl.get_option_text(self.index) @text.setter def text(self, value): - self._interface._impl.set_option_text(self.index, value) - - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - # label replaced with text - @property - def label(self): - """OptionItem text. - - **DEPRECATED: renamed as text** - - Returns: - The OptionItem text as a ``str`` - """ - warnings.warn( - "OptionItem.label has been renamed OptionItem.text", DeprecationWarning - ) - return self.text + if value is None: + raise ValueError("Item text cannot be None") - @label.setter - def label(self, label): - warnings.warn( - "OptionItem.label has been renamed OptionItem.text", DeprecationWarning - ) - self.text = label + text = str(value) + if not text: + raise ValueError("Item text cannot be blank") - ###################################################################### - # End backwards compatibility. - ###################################################################### - - -class OptionItem(BaseOptionItem): - """OptionItem is an interface wrapper for a tab on the OptionContainer.""" - - def __init__(self, interface, widget, index): - super().__init__(interface) - self._content = widget - self._index = index + self._interface._impl.set_option_text(self.index, text) @property - def index(self): + def index(self) -> int: + """The index of the tab in the OptionContainer.""" return self._index @property - def content(self): + def content(self) -> Widget: + """The content widget displayed in this tab of the OptionContainer.""" return self._content - def refresh(self): - self._content.refresh() - - -class CurrentOptionItem(BaseOptionItem): - """CurrentOptionItem is a proxy for whichever tab is currently selected.""" - - @property - def index(self): - return self._interface._impl.get_current_tab_index() - - @property - def content(self): - return self._interface.content[self.index].content - - def __add__(self, other): - if not isinstance(other, int): - raise ValueError("Cannot add non-integer value to OptionItem") - return self._interface.content[self.index + other] - - def __sub__(self, other): - if not isinstance(other, int): - raise ValueError("Cannot add non-integer value to OptionItem") - return self._interface.content[self.index - other] - - def refresh(self): - self._interface.content[self.index]._content.refresh() - class OptionList: def __init__(self, interface): @@ -111,21 +62,26 @@ def __init__(self, interface): self._options = [] def __repr__(self): - repr_optionlist = "{}([{}])" - repr_items = ", ".join( - [f"{option.__class__.__name__}(title={option.text})" for option in self] - ) - return repr_optionlist.format(self.__class__.__name__, repr_items) + items = ", ".join(repr(option.text) for option in self) + return f"" + + def __getitem__(self, index: int | str | OptionItem) -> OptionItem: + """Obtain a specific tab of content.""" + return self._options[self.index(index)] - # def __setitem__(self, index, option): - # TODO: replace tab content at the given index. - # self._options[index] = option - # option._index = index + def __delitem__(self, index: int | str | OptionItem): + """Same as :any:`remove`.""" + self.remove(index) - def __getitem__(self, index): - return self._options[index] + def remove(self, index: int | str | OptionItem): + """Remove the specified tab of content. + + The currently selected item cannot be deleted. + """ + index = self.index(index) + if index == self.interface._impl.get_current_tab_index(): + raise ValueError("The currently selected tab cannot be deleted.") - def __delitem__(self, index): self.interface._impl.remove_content(index) del self._options[index] # Update the index for each of the options @@ -133,122 +89,65 @@ def __delitem__(self, index): for option in self._options[index:]: option._index -= 1 - def __iter__(self): - return iter(self._options) + # Refresh the widget + self.interface.refresh() - def __len__(self): + def __len__(self) -> int: + """The number of tabs of content in the OptionContainer.""" return len(self._options) - def append( - self, - text=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label`` is removed - widget=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label`` is removed - label=None, # DEPRECATED! - enabled=True, - ): - ################################################################## - # 2022-07: Backwards compatibility - ################################################################## - # When deleting this block, also delete the NOT_PROVIDED - # placeholder, and replace its usage in default values. - missing_arguments = [] - - # label replaced with text - if label is not None: - if text is not NOT_PROVIDED: - raise ValueError( - "Cannot specify both `label` and `text`; " - "`label` has been deprecated, use `text`" - ) - else: - warnings.warn("label has been renamed text", DeprecationWarning) - text = label - elif text is NOT_PROVIDED: - missing_arguments.append("text") - - if widget is NOT_PROVIDED: - missing_arguments.append("widget") - - # This would be raised by Python itself; however, we need to use a placeholder - # value as part of the migration from text->value. - if len(missing_arguments) == 1: - raise TypeError( - f"OptionList.append missing 1 required positional argument: '{missing_arguments[0]}'" - ) - elif len(missing_arguments) > 1: - raise TypeError( - "OptionList.append missing {} required positional arguments: {}".format( - len(missing_arguments), - " and ".join([f"'{name}'" for name in missing_arguments]), - ) - ) - - ################################################################## - # End backwards compatibility. - ################################################################## - - self._insert(len(self), text, widget, enabled) + def index(self, value: str | int | OptionItem): + """Find the index of the tab that matches the given value. + + :param value: The value to look for. An integer is returned as-is; + if an :any:`OptionItem` is provided, that item's index is returned; + any other value will be converted into a string, and the first + tab with a label matching that string will be returned. + :raises ValueError: If no tab matching the value can be found. + """ + if isinstance(value, int): + return value + elif isinstance(value, OptionItem): + return value.index + else: + try: + return next(filter(lambda item: item.text == str(value), self)).index + except StopIteration: + raise ValueError(f"No tab named {value!r}") + + def append(self, text: str, widget: Widget, enabled: bool = True): + """Add a new tab of content to the OptionContainer. + + :param text: The text label for the new tab + :param widget: The content widget to use for the new tab. + """ + self.insert(len(self), text, widget, enabled=enabled) def insert( self, - index, - text=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label`` is removed - widget=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label`` is removed - label=None, # DEPRECATED! - enabled=True, + index: int | str | OptionItem, + text: str, + widget: Widget, + enabled: bool = True, ): - ################################################################## - # 2022-07: Backwards compatibility - ################################################################## - # When deleting this block, also delete the NOT_PROVIDED - # placeholder, and replace its usage in default values. - missing_arguments = [] - - # label replaced with text - if label is not None: - if text is not NOT_PROVIDED: - raise ValueError( - "Cannot specify both `label` and `text`; " - "`label` has been deprecated, use `text`" - ) - else: - warnings.warn("label has been renamed text", DeprecationWarning) - text = label - elif text is NOT_PROVIDED: - missing_arguments.append("text") - - if widget is NOT_PROVIDED: - missing_arguments.append("widget") - - # This would be raised by Python itself; however, we need to use a placeholder - # value as part of the migration from text->value. - if len(missing_arguments) == 1: - raise TypeError( - f"OptionList.insert missing 1 required positional argument: '{missing_arguments[0]}'" - ) - elif len(missing_arguments) > 1: - raise TypeError( - "OptionList.insert missing {} required positional arguments: {}".format( - len(missing_arguments), - " and ".join([f"'{name}'" for name in missing_arguments]), - ) - ) - - ################################################################## - # End backwards compatibility. - ################################################################## - - self._insert(index, text, widget, enabled) - - def _insert(self, index, text, widget, enabled=True): + """Insert a new tab of content to the OptionContainer at the specified index. + + :param index: The index where the new tab should be inserted. + :param text: The text label for the new tab. + :param widget: The content widget to use for the new tab. + :param enabled: Should the new tab be enabled? + """ + # Convert the index into an integer + index = self.index(index) + + # Validate item text + if text is None: + raise ValueError("Item text cannot be None") + + text = str(text) + if not text: + raise ValueError("Item text cannot be blank") + # Create an interface wrapper for the option. item = OptionItem(self.interface, widget, index) @@ -265,86 +164,82 @@ def _insert(self, index, text, widget, enabled=True): # The option now exists on the implementation; # finalize the display properties that can't be resolved until the # implementation exists. - widget.refresh() + self.interface.refresh() item.enabled = enabled class OptionContainer(Widget): - """The option container widget. - - Args: - id (str): An identifier for this widget. - style (:obj:`Style`): an optional style object. - If no style is provided then a new one will be created for the widget. - content (``list`` of ``tuple`` (``str``, :class:`~toga.Widget`)): - Each tuple in the list is composed of a title for the option and - the widget tree that is displayed in the option. - """ - - class OptionException(ValueError): - pass - def __init__( self, id=None, style=None, - content=None, - on_select=None, - factory=None, # DEPRECATED! + content: list[tuple[str, Widget]] | None = None, + on_select: callable | None = None, ): - super().__init__(id=id, style=style) - ###################################################################### - # 2022-09: Backwards compatibility - ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) - ###################################################################### - # End backwards compatibility. - ###################################################################### + """Create a new OptionContainer. + + Inherits from :class:`toga.Widget`. + :param id: The ID for the widget. + :param style: A style object. If no style is provided, a default style will be + applied to the widget. + :param content: The initial content to display in the OptionContainer. A list of + 2-tuples, each of which is the title for the option, and the content widget + to display for that title. + :param on_select: Initial :any:`on_select` handler. + """ + super().__init__(id=id, style=style) self._content = OptionList(self) - self._on_select = None + self.on_select = None + self._impl = self.factory.OptionContainer(interface=self) - self.on_select = on_select if content: for text, widget in content: - self.add(text, widget) + self.content.append(text, widget) self.on_select = on_select - # Create a proxy object to represent the currently selected item. - self._current_tab = CurrentOptionItem(self) @property - def content(self): - """The sub layouts of the :class:`OptionContainer`. - - Returns: - A OptionList ``list`` of :class:`~toga.OptionItem`. Each element of the list - is a sub layout of the `OptionContainer` + def enabled(self) -> bool: + """Is the widget currently enabled? i.e., can the user interact with the widget? - Raises: - :exp:`ValueError`: If the list is less than two elements long. + OptionContainer widgets cannot be disabled; this property will always return + True; any attempt to modify it will be ignored. """ + return True + + @enabled.setter + def enabled(self, value): + pass + + def focus(self): + "No-op; OptionContainer cannot accept input focus" + pass + + @property + def content(self) -> OptionList: + """The tabs of content currently managed by the OptionContainer.""" return self._content @property - def current_tab(self): - return self._current_tab + def current_tab(self) -> OptionItem | None: + """The currently selected tab of content, or ``None`` if there are no tabs. + + This property can also be set with an ``int`` index, or a ``str`` label. + """ + index = self._impl.get_current_tab_index() + if index is None: + return None + return self._content[index] @current_tab.setter - def current_tab(self, current_tab): - if isinstance(current_tab, str): - try: - current_tab = next( - filter(lambda item: item.text == current_tab, self.content) - ) - except StopIteration: - raise ValueError(f"No tab named {current_tab}") - if isinstance(current_tab, OptionItem): - current_tab = current_tab.index - self._impl.set_current_tab_index(current_tab) + def current_tab(self, value): + index = self._content.index(value) + if not self._impl.is_option_enabled(index): + raise ValueError("A disabled tab cannot be made the current tab.") + + self._impl.set_current_tab_index(index) @Widget.app.setter def app(self, app): @@ -364,104 +259,11 @@ def window(self, window): for item in self._content: item._content.window = window - def add( - self, - text=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label`` is removed - widget=NOT_PROVIDED, # BACKWARDS COMPATIBILITY: The default value - # can be removed when the handling for - # `label`` is removed - label=None, # DEPRECATED! - ): - """Add a new option to the option container. - - Args: - text (str): The text for the option. - widget (:class:`toga.Widget`): The widget to add to the option. - """ - ################################################################## - # 2022-07: Backwards compatibility - ################################################################## - # When deleting this block, also delete the NOT_PROVIDED - # placeholder, and replace its usage in default values. - missing_arguments = [] - - # label replaced with text - if label is not None: - if text is not NOT_PROVIDED: - raise ValueError( - "Cannot specify both `label` and `text`; " - "`label` has been deprecated, use `text`" - ) - else: - warnings.warn("label has been renamed text", DeprecationWarning) - text = label - elif text is NOT_PROVIDED: - missing_arguments.append("text") - - if widget is NOT_PROVIDED: - missing_arguments.append("widget") - - # This would be raised by Python itself; however, we need to use a placeholder - # value as part of the migration from text->value. - if len(missing_arguments) == 1: - raise TypeError( - f"OptionContainer.add missing 1 required positional argument: '{missing_arguments[0]}'" - ) - elif len(missing_arguments) > 1: - raise TypeError( - "OptionContainer.add missing {} required positional arguments: {}".format( - len(missing_arguments), - " and ".join([f"'{name}'" for name in missing_arguments]), - ) - ) - - ################################################################## - # End backwards compatibility. - ################################################################## - - widget.app = self.app - widget.window = self.window - - self._content.append(text, widget) - - def insert(self, index, text, widget): - """Insert a new option at the specified index. - - Args: - index (int): Index for the option. - text (str): The text for the option. - widget (:class:`toga.Widget`): The widget to add to the option. - """ - widget.app = self.app - widget.window = self.window - - self._content.insert(index, text, widget) - - def remove(self, index): - del self._content[index] - - def refresh_sublayouts(self): - """Refresh the layout and appearance of this widget.""" - for widget in self._content: - widget.refresh() - @property - def on_select(self): - """The callback function that is invoked when one of the options is selected. - - Returns: - (``Callable``) The callback function. - """ + def on_select(self) -> callable: + """The callback to invoke when a new tab of content is selected.""" return self._on_select @on_select.setter def on_select(self, handler): - """Set the function to be executed on option selection. - - :param handler: callback function - :type handler: ``Callable`` - """ self._on_select = wrapped_handler(self, handler) - self._impl.set_on_select(self._on_select) diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index f27ed66829..afc49d689c 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -80,12 +80,6 @@ def test_detailed_list_created(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_option_container_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.OptionContainer(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_table_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Table( diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index 7d3d007c57..302bb4e095 100644 --- a/core/tests/test_handlers.py +++ b/core/tests/test_handlers.py @@ -102,7 +102,7 @@ def handler(*args, **kwargs): def test_function_handler_with_cleanup_error(capsys): - """A function handler can have a cleanup method that raises an error""" + """A function handler can have a cleanup method that raises an error.""" obj = Mock() cleanup = Mock(side_effect=Exception("Problem in cleanup")) handler_call = {} diff --git a/core/tests/widgets/test_optioncontainer.py b/core/tests/widgets/test_optioncontainer.py index 0dbef38fde..505f34d64c 100644 --- a/core/tests/widgets/test_optioncontainer.py +++ b/core/tests/widgets/test_optioncontainer.py @@ -1,284 +1,471 @@ -from unittest import mock +from unittest.mock import Mock + +import pytest import toga -from toga_dummy.utils import TestCase, TestStyle - - -class OptionContainerTests(TestCase): - def setUp(self): - super().setUp() - - self.on_select = mock.Mock() - self.op_container = toga.OptionContainer( - style=TestStyle(), on_select=self.on_select - ) - self.widget = toga.Box(style=TestStyle()) - self.text2, self.widget2 = "Widget 2", toga.Box(style=TestStyle()) - self.text3, self.widget3 = "Widget 3", toga.Box(style=TestStyle()) - self.text = "New Container" - self.op_container.add(self.text, self.widget) - - def assert_tab(self, tab, index, text, widget, enabled): - self.assertEqual(tab.index, index) - self.assertEqual(tab.text, text) - self.assertEqual(tab._interface, self.op_container) - self.assertEqual(tab.enabled, enabled) - self.assertEqual(tab.content, widget) - - def add_widgets(self): - self.op_container.add(self.text2, self.widget2) - self.op_container.add(self.text3, self.widget3) - - def test_on_select(self): - self.assertEqual(self.op_container.on_select._raw, self.on_select) - - def test_widget_created(self): - self.assertEqual(self.op_container._impl.interface, self.op_container) - self.assertActionPerformed(self.op_container, "create OptionContainer") - - def test_adding_container_invokes_add_content(self): - self.assertActionPerformedWith( - self.op_container, "add content", text=self.text, widget=self.widget._impl - ) - - def test_widget_refresh_sublayouts(self): - # Clear event log to verify new set bounds for refresh - self.reset_event_log() - - self.op_container.refresh_sublayouts() - - def test_set_current_tab_as_index(self): - self.add_widgets() - self.op_container.current_tab = 1 - self.assert_tab( - self.op_container.current_tab, - index=1, - text=self.text2, - widget=self.widget2, - enabled=True, - ) - - def test_set_current_tab_as_label(self): - self.add_widgets() - self.op_container.current_tab = self.text3 - self.assert_tab( - self.op_container.current_tab, - index=2, - text=self.text3, - widget=self.widget3, - enabled=True, - ) - - def test_set_current_tab_as_tab(self): - self.add_widgets() - self.op_container.current_tab = self.op_container.content[1] - self.assert_tab( - self.op_container.current_tab, - index=1, - text=self.text2, - widget=self.widget2, - enabled=True, - ) - - def test_current_tab_increment(self): - self.add_widgets() - self.op_container.current_tab = 1 - self.op_container.current_tab += 1 - self.assert_tab( - self.op_container.current_tab, - index=2, - text=self.text3, - widget=self.widget3, - enabled=True, - ) - - def test_set_current_tab_as_text_raises_an_error(self): - self.add_widgets() - - def set_text(): - self.op_container.current_tab = "I do not exist!" - - self.assertRaises(ValueError, set_text) - - def test_current_tab_string_increment_raises_an_error(self): - self.add_widgets() - - def set_text(): - self.op_container.current_tab += "I do not exist!" - - self.assertRaises(ValueError, set_text) - - def test_current_tab_string_decrement_raises_an_error(self): - self.add_widgets() - - def set_text(): - self.op_container.current_tab -= "I do not exist!" - - self.assertRaises(ValueError, set_text) - - def test_current_tab_decrement(self): - self.add_widgets() - self.op_container.current_tab = 1 - self.op_container.current_tab -= 1 - self.assert_tab( - self.op_container.current_tab, - index=0, - text=self.text, - widget=self.widget, - enabled=True, - ) - - def test_disable_tab(self): - self.op_container.current_tab.enabled = False - self.assertEqual(self.op_container.current_tab.enabled, False) - - def test_content_repr(self): - self.add_widgets() - self.assertEqual( - ( - "OptionList([OptionItem(title=New Container), " - "OptionItem(title=Widget 2), " - "OptionItem(title=Widget 3)])" - ), - repr(self.op_container.content), - ) - - def test_add_tabs(self): - self.add_widgets() - self.assertEqual(len(self.op_container.content), 3) - self.assertEqual(self.op_container.content[0]._content, self.widget) - self.assertEqual(self.op_container.content[1]._content, self.widget2) - self.assertEqual(self.op_container.content[2]._content, self.widget3) - - def test_remove_tab(self): - self.add_widgets() - self.op_container.remove(1) - self.assertEqual(len(self.op_container.content), 2) - self.assertEqual(self.op_container.content[0]._content, self.widget) - self.assertEqual(self.op_container.content[1]._content, self.widget3) - - def test_set_content_in_constructor(self): - new_container = toga.OptionContainer( - style=TestStyle(), - content=[ - (self.text, self.widget), - (self.text2, self.widget2), - (self.text3, self.widget3), - ], - ) - self.assertEqual(len(new_container.content), 3) - self.assertEqual(new_container.content[0]._content, self.widget) - self.assertEqual(new_container.content[1]._content, self.widget2) - self.assertEqual(new_container.content[2]._content, self.widget3) - - def test_set_window(self): - window = mock.Mock() - self.op_container.window = window - for item in self.op_container.content: - self.assertEqual(item._content.window, window) - - def test_set_tab_title(self): - new_text = "New Title" - self.op_container.content[0].text = new_text - self.assertEqual(self.op_container.content[0].text, new_text) - - def test_insert_tab(self): - self.op_container.insert(0, text=self.text2, widget=self.widget2) - self.assertEqual(self.op_container.content[0].text, self.text2) - - def test_set_app(self): - app = mock.Mock() - self.op_container.app = app - for item in self.op_container.content: - self.assertEqual(item._content.app, app) - - ###################################################################### - # 2022-07: Backwards compatibility - ###################################################################### - - def test_tab_label_deprecated(self): - new_text = "New Text" - with self.assertWarns(DeprecationWarning): - self.assertEqual(self.op_container.current_tab.label, self.text) - with self.assertWarns(DeprecationWarning): - self.op_container.current_tab.label = new_text - self.assertEqual(self.op_container.current_tab.text, new_text) - - def test_add_tab_deprecated(self): - # label is a deprecated argument - with self.assertWarns(DeprecationWarning): - self.op_container.add(label=self.text2, widget=self.widget2) - self.assertEqual(self.op_container.content[1].text, self.text2) - - # can't specify both label *and* text - with self.assertRaises(ValueError): - self.op_container.add( - text=self.text3, widget=self.widget3, label=self.text3 - ) - - def test_append_tab_deprecated(self): - # label is a deprecated argument - with self.assertWarns(DeprecationWarning): - self.op_container.content.append(label=self.text2, widget=self.widget2) - self.assertEqual(self.op_container.content[1].text, self.text2) - - # can't specify both label *and* text - with self.assertRaises(ValueError): - self.op_container.content.append( - text=self.text3, widget=self.widget3, label=self.text3 - ) - - def test_insert_tab_deprecated(self): - # label is a deprecated argument - with self.assertWarns(DeprecationWarning): - self.op_container.content.insert(1, label=self.text2, widget=self.widget2) - self.assertEqual(self.op_container.content[1].text, self.text2) - - # can't specify both label *and* text - with self.assertRaises(ValueError): - self.op_container.content.insert( - 1, text=self.text3, widget=self.widget3, label=self.text3 - ) - - def test_add_mandatory_parameters(self): - my_op_container = toga.OptionContainer( - style=TestStyle(), on_select=self.on_select - ) - - # text and widget parameters are mandatory - with self.assertRaises(TypeError): - my_op_container.add() - with self.assertRaises(TypeError): - my_op_container.add(self.text) - with self.assertRaises(TypeError): - my_op_container.add(widget=self.widget) - - def test_append_mandatory_parameters(self): - my_op_container = toga.OptionContainer( - style=TestStyle(), on_select=self.on_select - ) - - # text and widget parameters are mandatory - with self.assertRaises(TypeError): - my_op_container.content.append() - with self.assertRaises(TypeError): - my_op_container.content.append(self.text) - with self.assertRaises(TypeError): - my_op_container.content.append(widget=self.widget) - - def test_insert_mandatory_parameters(self): - my_op_container = toga.OptionContainer( - style=TestStyle(), on_select=self.on_select - ) - - # text and widget parameters are mandatory - with self.assertRaises(TypeError): - my_op_container.content.insert(0) - with self.assertRaises(TypeError): - my_op_container.content.insert(0, self.text) - with self.assertRaises(TypeError): - my_op_container.content.insert(0, widget=self.widget) - - ###################################################################### - # End backwards compatibility. - ###################################################################### +from toga_dummy.utils import ( + assert_action_not_performed, + assert_action_performed, + assert_action_performed_with, +) + + +@pytest.fixture +def app(): + return toga.App("Option Container Test", "org.beeware.toga.option_container") + + +@pytest.fixture +def window(): + return toga.Window() + + +@pytest.fixture +def content1(): + return toga.Box() + + +@pytest.fixture +def content2(): + return toga.Box() + + +@pytest.fixture +def content3(): + return toga.Box() + + +@pytest.fixture +def on_select_handler(): + return Mock() + + +@pytest.fixture +def optioncontainer(content1, content2, content3, on_select_handler): + return toga.OptionContainer( + content=[("Item 1", content1), ("Item 2", content2), ("Item 3", content3)], + on_select=on_select_handler, + ) + + +def test_widget_create(): + "An option container can be created with no arguments" + optioncontainer = toga.OptionContainer() + assert_action_performed(optioncontainer, "create OptionContainer") + + assert len(optioncontainer.content) == 0 + assert optioncontainer.current_tab is None + assert optioncontainer.on_select._raw is None + + +def test_widget_create_with_args(optioncontainer, on_select_handler): + "An option container can be created with arguments" + assert optioncontainer._impl.interface == optioncontainer + assert_action_performed(optioncontainer, "create OptionContainer") + + assert len(optioncontainer.content) == 3 + assert optioncontainer.current_tab.text == "Item 1" + assert optioncontainer.on_select._raw == on_select_handler + + +def test_assign_to_app(app, optioncontainer, content1, content2, content3): + """If the widget is assigned to an app, the content is also assigned""" + # Option container is initially unassigned + assert optioncontainer.app is None + + # Assign the option container to the app + optioncontainer.app = app + + # option container is on the app + assert optioncontainer.app == app + + # Content is also on the app + assert content1.app == app + assert content2.app == app + assert content3.app == app + + +def test_assign_to_app_no_content(app): + """If the widget is assigned to an app, and there is no content, there's no error""" + optioncontainer = toga.OptionContainer() + + # Option container is initially unassigned + assert optioncontainer.app is None + + # Assign the Option container to the app + optioncontainer.app = app + + # Option container is on the app + assert optioncontainer.app == app + + +def test_assign_to_window(window, optioncontainer, content1, content2, content3): + """If the widget is assigned to a window, the content is also assigned""" + # Option container is initially unassigned + assert optioncontainer.window is None + + # Assign the Option container to the window + optioncontainer.window = window + + # Option container is on the window + assert optioncontainer.window == window + # Content is also on the window + assert content1.window == window + assert content2.window == window + assert content3.window == window + + +def test_assign_to_window_no_content(window): + """If the widget is assigned to a window, and there is no content, there's no error""" + optioncontainer = toga.OptionContainer() + + # Option container is initially unassigned + assert optioncontainer.window is None + + # Assign the Option container to the window + optioncontainer.window = window + + # Option container is on the window + assert optioncontainer.window == window + + +def test_disable_no_op(optioncontainer): + """OptionContainer doesn't have a disabled state""" + # Enabled by default + assert optioncontainer.enabled + + # Try to disable the widget + optioncontainer.enabled = False + + # Still enabled. + assert optioncontainer.enabled + + +def test_focus_noop(optioncontainer): + """Focus is a no-op.""" + + optioncontainer.focus() + assert_action_not_performed(optioncontainer, "focus") + + +@pytest.mark.parametrize( + "value, expected", + [ + (None, False), + ("", False), + ("true", True), + ("false", True), # Evaluated as a string, this value is true. + (0, False), + (1234, True), + ], +) +def test_item_enabled(optioncontainer, value, expected): + """The enabled status of an item can be changed.""" + item = optioncontainer.content[1] + + # item is initially enabled by default. + assert item.enabled + + # Set the enabled status + item.enabled = value + assert item.enabled == expected + + # Disable the widget + item.enabled = False + assert not item.enabled + + # Set the enabled status again + item.enabled = value + assert item.enabled == expected + + +def test_disable_current_item(optioncontainer): + """The currently selected item cannot be disabled""" + # Item 0 is selected by default + item = optioncontainer.content[0] + with pytest.raises( + ValueError, + match=r"The currently selected tab cannot be disabled.", + ): + item.enabled = False + + # Try disabling the current tab directly + with pytest.raises( + ValueError, + match=r"The currently selected tab cannot be disabled.", + ): + optioncontainer.current_tab.enabled = False + + +class MyTitle: + def __init__(self, title): + self.title = title + + def __str__(self): + return self.title + + +@pytest.mark.parametrize( + "value, expected", + [ + ("New Title", "New Title"), + (42, "42"), # Evaluated as a string + (MyTitle("Custom Title"), "Custom Title"), # Evaluated as a string + ], +) +def test_item_text(optioncontainer, value, expected): + """The title of an item can be changed.""" + item = optioncontainer.content[1] + + # Set the item text + item.text = value + assert item.text == expected + + +@pytest.mark.parametrize( + "value, error", + [ + (None, r"Item text cannot be None"), + ("", r"Item text cannot be blank"), + (MyTitle(""), r"Item text cannot be blank"), + ], +) +def test_invalid_item_text(optioncontainer, value, error): + """Invalid item titles are prevented""" + item = optioncontainer.content[1] + + # Using invalid text raises an error + with pytest.raises(ValueError, match=error): + item.text = value + + +def test_optionlist_repr(optioncontainer): + """OptionContainer content has a helpful repr""" + assert repr(optioncontainer.content) == "" + + +def test_optionlist_iter(optioncontainer): + """OptionContainer content can be iterated""" + assert [item.text for item in optioncontainer.content] == [ + "Item 1", + "Item 2", + "Item 3", + ] + + +def test_optionlist_len(optioncontainer): + """OptionContainer content has length""" + assert len(optioncontainer.content) == 3 + + +@pytest.mark.parametrize("index", [1, "Item 2", None]) +def test_getitem(optioncontainer, content2, index): + """An item can be retrieved""" + if index is None: + index = optioncontainer.content[1] + + # get item + item = optioncontainer.content[index] + assert item.text == "Item 2" + assert item.index == 1 + assert item.content == content2 + + +@pytest.mark.parametrize("index", [1, "Item 2", None]) +def test_delitem(optioncontainer, index): + """An item can be removed with __del__""" + if index is None: + index = optioncontainer.content[1] + + # get a reference to items 1 and 3 + item1 = optioncontainer.content[0] + item3 = optioncontainer.content[2] + + # delete item + del optioncontainer.content[index] + assert len(optioncontainer.content) == 2 + assert_action_performed_with(optioncontainer, "remove content", index=1) + + # There's no item with the deleted label + with pytest.raises(ValueError, match=r"No tab named 'Item 2'"): + optioncontainer.content.index("Item 2") + + # The index of item 3 has been reduced; item 1 is untouched + assert item1.index == 0 + assert item3.index == 1 + + # Widget has been refreshed + assert_action_performed(optioncontainer, "refresh") + + +@pytest.mark.parametrize("index", [0, "Item 1", None]) +def test_delitem_current(optioncontainer, index): + """The current item can't be deleted""" + if index is None: + index = optioncontainer.content[0] + + with pytest.raises( + ValueError, match=r"The currently selected tab cannot be deleted." + ): + del optioncontainer.content[index] + + +@pytest.mark.parametrize("index", [1, "Item 2", None]) +def test_item_remove(optioncontainer, index): + """An item can be removed with remove""" + if index is None: + index = optioncontainer.content[1] + + # get a reference to items 1 and 3 + item1 = optioncontainer.content[0] + item3 = optioncontainer.content[2] + + # remove item + optioncontainer.content.remove(index) + assert len(optioncontainer.content) == 2 + assert_action_performed_with(optioncontainer, "remove content", index=1) + + # There's no item with the deleted label + with pytest.raises(ValueError, match=r"No tab named 'Item 2'"): + optioncontainer.content.index("Item 2") + + # The index of item 3 has been reduced; item 1 is untouched + assert item1.index == 0 + assert item3.index == 1 + + # Widget has been refreshed + assert_action_performed(optioncontainer, "refresh") + + +@pytest.mark.parametrize("index", [0, "Item 1", None]) +def test_item_remove_current(optioncontainer, index): + """The current item can't be removed""" + if index is None: + index = optioncontainer.content[0] + + with pytest.raises( + ValueError, match=r"The currently selected tab cannot be deleted." + ): + optioncontainer.content.remove(index) + + +@pytest.mark.parametrize( + "value, expected", + [ + ("New Title", "New Title"), + (42, "42"), # Evaluated as a string + (MyTitle("Custom Title"), "Custom Title"), # Evaluated as a string + ], +) +def test_item_insert_text(optioncontainer, value, expected): + """The text of an inserted item can be set""" + new_content = toga.Box() + + optioncontainer.content.insert(1, value, new_content, enabled=True) + + # Backend added an item and set enabled + assert_action_performed_with( + optioncontainer, + "add content", + index=1, + text=expected, + widget=new_content._impl, + ) + assert_action_performed_with( + optioncontainer, + "set option enabled", + index=1, + value=True, + ) + assert_action_performed_with(optioncontainer, "refresh") + + +@pytest.mark.parametrize( + "value, error", + [ + (None, r"Item text cannot be None"), + ("", r"Item text cannot be blank"), + (MyTitle(""), r"Item text cannot be blank"), + ], +) +def test_item_insert_invalid_text(optioncontainer, value, error): + """The item text must be valid""" + new_content = toga.Box() + with pytest.raises(ValueError, match=error): + optioncontainer.content.insert(1, value, new_content, enabled=True) + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_item_insert_enabled(optioncontainer, enabled): + """The enabled status of content can be set""" + new_content = toga.Box() + + optioncontainer.content.insert(1, "New content", new_content, enabled=enabled) + + # Backend added an item and set enabled + assert_action_performed_with( + optioncontainer, + "add content", + index=1, + text="New content", + widget=new_content._impl, + ) + assert_action_performed_with( + optioncontainer, + "set option enabled", + index=1, + value=enabled, + ) + assert_action_performed_with(optioncontainer, "refresh") + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_item_append(optioncontainer, enabled): + """An item can be appended to the content list""" + # append is implemented using insert; + # the bulk of the functionality is tested there. + new_content = toga.Box() + + optioncontainer.content.append("New content", new_content, enabled=enabled) + assert_action_performed_with( + optioncontainer, "add content", index=3, widget=new_content._impl + ) + assert_action_performed_with( + optioncontainer, "set option enabled", index=3, value=enabled + ) + assert_action_performed_with(optioncontainer, "refresh") + + +@pytest.mark.parametrize("index", [1, "Item 2", None]) +def test_current_tab(optioncontainer, index, on_select_handler): + """The current tab of the optioncontainer can be changed.""" + if index is None: + index = optioncontainer.content[1] + + # First item is selected initially + assert optioncontainer.current_tab.index == 0 + assert optioncontainer.current_tab.text == "Item 1" + + # Programmatically select item 2 + optioncontainer.current_tab = index + + # Current tab values have changed + assert optioncontainer.current_tab.index == 1 + assert optioncontainer.current_tab.text == "Item 2" + + # on_select handler was invoked + on_select_handler.assert_called_once_with(optioncontainer) + + +def test_select_disabled_tab(optioncontainer): + """A disabled tab cannot be selected.""" + + # Disable item 1 + item = optioncontainer.content[1] + item.enabled = False + + with pytest.raises( + ValueError, + match=r"A disabled tab cannot be made the current tab.", + ): + optioncontainer.current_tab = 1 diff --git a/demo/toga_demo/app.py b/demo/toga_demo/app.py index 261ccc5579..b4e020bb5d 100755 --- a/demo/toga_demo/app.py +++ b/demo/toga_demo/app.py @@ -8,8 +8,6 @@ def startup(self): # Create the main window self.main_window = toga.MainWindow(self.name) - left_container = toga.OptionContainer() - left_table = toga.Table( headings=["Hello", "World"], data=[ @@ -35,8 +33,12 @@ def startup(self): }, ) - left_container.add("Table", left_table) - left_container.add("Tree", left_tree) + left_container = toga.OptionContainer( + content=[ + ("Table", left_table), + ("Tree", left_tree), + ] + ) right_content = toga.Box(style=Pack(direction=COLUMN)) for b in range(0, 10): diff --git a/docs/conf.py b/docs/conf.py index bcde5b6d18..cefce41d3c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -67,6 +67,10 @@ autoclass_content = "both" autodoc_preserve_defaults = True +autodoc_default_options = { + "members": True, + "undoc-members": True, +} # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/reference/api/containers/box.rst b/docs/reference/api/containers/box.rst index 173928088a..e7df9dcaf3 100644 --- a/docs/reference/api/containers/box.rst +++ b/docs/reference/api/containers/box.rst @@ -49,5 +49,3 @@ Reference --------- .. autoclass:: toga.Box - :members: - :undoc-members: diff --git a/docs/reference/api/containers/optioncontainer.rst b/docs/reference/api/containers/optioncontainer.rst index 32c594836b..d31cad01a6 100644 --- a/docs/reference/api/containers/optioncontainer.rst +++ b/docs/reference/api/containers/optioncontainer.rst @@ -1,6 +1,12 @@ OptionContainer =============== +A container that can display multiple labeled tabs of content. + +.. figure:: /reference/images/OptionContainer.png + :align: center + :width: 300px + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,10 +14,6 @@ OptionContainer :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!(OptionContainer|Component))'} -The Option Container widget is a user-selection control for choosing from a pre-configured list of controls, like a tab view. - -.. figure:: /reference/images/OptionContainer.jpeg - :align: center Usage ----- @@ -20,17 +22,62 @@ Usage import toga - container = toga.OptionContainer() + pizza = toga.Box() + pasta = toga.Box() + + container = toga.OptionContainer( + content=[("Pizza", pizza), ("Pasta", pasta)] + ) + + # Add another tab of content + salad = toga.Box() + container.content.append("Salad", salad) + +When retrieving or deleting items, or when specifying the +currently selected item, you can specify an item using: + +* The index of the item in the list of content: - table = toga.Table(['Hello', 'World']) - tree = toga.Tree(['Navigate']) + .. code-block:: python - container.add('Table', table) - container.add('Tree', tree) + # Insert a new second tab + container.content.insert(1, "Soup", toga.Box()) + # Make the third tab the currently active tab + container.current_tab = 2 + # Delete the second tab + del container.content[1] + +* The string label of the tab: + + .. code-block:: python + + # Insert a tab at the index currently occupied by a tab labeled "Pasta" + container.content.insert("Pasta", "Soup", toga.Box()) + # Make the tab labeled "Pasta" the currently active tab + container.current_tab = "Pasta" + # Delete tab labeled "Pasta" + del container.content["Pasta"] + +* A reference to an :any:`OptionItem`: + + .. code-block:: python + + # Get a reference to the "Pasta" tab + pasta_tab = container.content["Pasta"] + # Insert content at the index currently occupied by the pasta tab + container.content.insert(pasta_tab, "Soup", toga.Box()) + # Make the pasta tab the currently active tab + container.current_tab = pasta_tab + # Delete the pasta tab + del container.content[pasta_tab] Reference --------- .. autoclass:: toga.OptionContainer - :members: - :undoc-members: + :exclude-members: app, window + +.. autoclass:: toga.widgets.optioncontainer.OptionList + :special-members: __getitem__, __delitem__ + +.. autoclass:: toga.widgets.optioncontainer.OptionItem diff --git a/docs/reference/api/containers/scrollcontainer.rst b/docs/reference/api/containers/scrollcontainer.rst index 1b92816249..15a927cb1b 100644 --- a/docs/reference/api/containers/scrollcontainer.rst +++ b/docs/reference/api/containers/scrollcontainer.rst @@ -30,6 +30,4 @@ Reference --------- .. autoclass:: toga.ScrollContainer - :members: - :undoc-members: :exclude-members: window, app diff --git a/docs/reference/api/containers/splitcontainer.rst b/docs/reference/api/containers/splitcontainer.rst index 8fea0b6805..f221e93f3a 100644 --- a/docs/reference/api/containers/splitcontainer.rst +++ b/docs/reference/api/containers/splitcontainer.rst @@ -65,6 +65,4 @@ Reference --------- .. autoclass:: toga.SplitContainer - :members: - :undoc-members: :exclude-members: HORIZONTAL, VERTICAL, window, app diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 0ec2d6daf0..56ee42a770 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -64,7 +64,7 @@ Layout widgets the container, with overflow controlled by scroll bars. :doc:`SplitContainer ` A container that divides an area into two panels with a movable border. - :doc:`OptionContainer ` Option Container + :doc:`OptionContainer ` A container that can display multiple labeled tabs of content. ==================================================================== ======================================================================== Resources diff --git a/docs/reference/api/widgets/widget.rst b/docs/reference/api/widgets/widget.rst index 52a7e0fb0a..ce5e873790 100644 --- a/docs/reference/api/widgets/widget.rst +++ b/docs/reference/api/widgets/widget.rst @@ -15,6 +15,4 @@ Reference --------- .. autoclass:: toga.Widget - :members: - :undoc-members: :inherited-members: diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 7e977e560c..77228f0e74 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -26,7 +26,7 @@ Widget,General Widget,:class:`~toga.Widget`,The base widget,|y|,|y|,|y|,|y|,|y|, Box,Layout Widget,:class:`~toga.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| ScrollContainer,Layout Widget,:class:`~toga.ScrollContainer`,A container that can display a layout larger than the area of the container,|y|,|y|,|y|,|y|,|y|, SplitContainer,Layout Widget,:class:`~toga.SplitContainer`,A container that divides an area into two panels with a movable border,|y|,|y|,|y|,,, -OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,Option Container,|b|,|b|,|b|,,, +OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,,, App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform-appropriate filesystem locations for an application.,|y|,|y|,|y|,|y|,|y|, Font,Resource,:class:`~toga.Font`,Fonts,|b|,|b|,|b|,|b|,|b|, Command,Resource,:class:`~toga.Command`,Command,|b|,|b|,|b|,,|b|, diff --git a/docs/reference/images/OptionContainer.jpeg b/docs/reference/images/OptionContainer.jpeg deleted file mode 100644 index eb5c18258b..0000000000 Binary files a/docs/reference/images/OptionContainer.jpeg and /dev/null differ diff --git a/docs/reference/images/OptionContainer.png b/docs/reference/images/OptionContainer.png new file mode 100644 index 0000000000..13c1f6f4f9 Binary files /dev/null and b/docs/reference/images/OptionContainer.png differ diff --git a/dummy/src/toga_dummy/widgets/optioncontainer.py b/dummy/src/toga_dummy/widgets/optioncontainer.py index 4ee263b262..d35e02d710 100644 --- a/dummy/src/toga_dummy/widgets/optioncontainer.py +++ b/dummy/src/toga_dummy/widgets/optioncontainer.py @@ -10,48 +10,44 @@ def __init__(self, text, widget, enabled): self.enabled = enabled +@not_required # Testbed coverage is complete for this widget. class OptionContainer(Widget): def create(self): self._action("create OptionContainer") self._items = [] - self._current_index = 0 def add_content(self, index, text, widget): self._action("add content", index=index, text=text, widget=widget) self._items.insert(index, Option(text, widget, True)) + # if this is the first item of content, set it as the selected item. + if len(self._items) == 1: + self.set_current_tab_index(0) + def remove_content(self, index): - if index == self._current_index: - # Don't allow removal of a selected tab - raise self.interface.OptionException( - "Currently selected option cannot be removed" - ) self._action("remove content", index=index) del self._items[index] - def set_on_select(self, handler): - self._set_value("on_select", handler) - def set_option_enabled(self, index, enabled): - self._set_value(f"option_{index}_enabled", value=enabled) + self._action("set option enabled", index=index, value=enabled) self._items[index].enabled = enabled def is_option_enabled(self, index): - self._get_value(f"option_{index}_enabled", None) return self._items[index].enabled def set_option_text(self, index, value): - self._set_value(f"option_{index}_text", value=value) + self._action("set option text", index=index, value=value) self._items[index].text = value def get_option_text(self, index): - self._get_value(f"option_{index}_text", None) return self._items[index].text def set_current_tab_index(self, current_tab_index): self._set_value("current_tab_index", current_tab_index) - self._current_index = current_tab_index + self.interface.on_select(None) def get_current_tab_index(self): - self._get_value("current_tab_index", 0) - return self._current_index + return self._get_value("current_tab_index", None) + + def simulate_select_tab(self, index): + self.set_current_tab_index(index) diff --git a/examples/optioncontainer/optioncontainer/app.py b/examples/optioncontainer/optioncontainer/app.py index 0b156eee4d..7e521aecc8 100644 --- a/examples/optioncontainer/optioncontainer/app.py +++ b/examples/optioncontainer/optioncontainer/app.py @@ -5,19 +5,22 @@ class ExampleOptionContainerApp(toga.App): def _create_options(self): - label_box0 = toga.Label("This is Box 0 ", style=Pack(padding=10)) - label_box1 = toga.Label("This is Box 1 ", style=Pack(padding=10)) - label_box2 = toga.Label("This is Box 2 ", style=Pack(padding=10)) - - box0 = toga.Box(children=[label_box0]) - box1 = toga.Box(children=[label_box1]) - box2 = toga.Box(children=[label_box2]) - - self.optioncontainer.add("Option 0", box0) - self.optioncontainer.add("Option 1", box1) - self.optioncontainer.add("Option 2", box2) + self._box_count = 0 + for i in range(3): + self.optioncontainer.content.append(*self._create_option()) self._refresh_select() + def _create_option(self): + result = ( + f"Option {self._box_count}", + toga.Box( + style=Pack(background_color="cyan", padding=10), + children=[toga.Label(f"This is Box {self._box_count}")], + ), + ) + self._box_count += 1 + return result + def _refresh_select(self): items = [] for i in range(len(self.optioncontainer.content)): @@ -25,12 +28,12 @@ def _refresh_select(self): self.select_option.items = items def on_add_option(self, button): - self.optioncontainer.add("New Option", toga.Box()) + self.optioncontainer.content.append(*self._create_option()) self._refresh_select() def on_insert_option(self, button): index = self.optioncontainer.current_tab.index - self.optioncontainer.content.insert(index, "New Option", toga.Box()) + self.optioncontainer.content.insert(index, *self._create_option()) self._refresh_select() def on_enable_option(self, button): @@ -39,7 +42,7 @@ def on_enable_option(self, button): self.optioncontainer.content[ index ].enabled = not self.optioncontainer.content[index].enabled - except toga.OptionContainer.OptionException as e: + except ValueError as e: self.main_window.info_dialog("Oops", str(e)) def on_change_option_title(self, button): @@ -50,7 +53,7 @@ def on_activate_option(self, button): try: index = int(self.select_option.value) self.optioncontainer.current_tab = index - except toga.OptionContainer.OptionException as e: + except ValueError as e: self.main_window.info_dialog("Oops", str(e)) def on_remove_option(self, button): @@ -58,7 +61,7 @@ def on_remove_option(self, button): index = int(self.select_option.value) del self.optioncontainer.content[index] self._refresh_select() - except toga.OptionContainer.OptionException as e: + except ValueError as e: self.main_window.info_dialog("Oops", str(e)) def set_next_tab(self, widget): @@ -72,10 +75,9 @@ def set_previous_tab(self, widget): if self.optioncontainer.current_tab.index > 0: self.optioncontainer.current_tab -= 1 - def on_select_tab(self, widget, option): - self.selected_label.text = "Tab {} has been chosen: {}".format( - option.index, - option.text, + def on_select_tab(self, widget, **kwargs): + self.selected_label.text = ( + f"Tab {widget.current_tab.index} has been chosen: {widget.current_tab.text}" ) def startup(self): @@ -109,17 +111,17 @@ def startup(self): children=[label_select, self.select_option], ) box_actions_1 = toga.Box( - style=Pack(direction=ROW, flex=1), + style=Pack(direction=ROW), children=[btn_activate, btn_remove, btn_enabled], ) box_actions_2 = toga.Box( - style=Pack(direction=ROW, flex=1), + style=Pack(direction=ROW), children=[self.input_change_title, btn_change_title], ) self.selected_label = toga.Label("") self.optioncontainer = toga.OptionContainer( - on_select=self.on_select_tab, style=Pack(padding_bottom=20) + on_select=self.on_select_tab, style=Pack(padding_bottom=20, flex=1) ) self._create_options() diff --git a/gtk/src/toga_gtk/widgets/optioncontainer.py b/gtk/src/toga_gtk/widgets/optioncontainer.py index 6cdf88c47b..ebf99ed718 100644 --- a/gtk/src/toga_gtk/widgets/optioncontainer.py +++ b/gtk/src/toga_gtk/widgets/optioncontainer.py @@ -5,43 +5,33 @@ class OptionContainer(Widget): def create(self): - # We want a single unified widget; the vbox is the representation of that widget. self.native = Gtk.Notebook() self.native.connect("switch-page", self.gtk_on_switch_page) + self.sub_containers = [] def gtk_on_switch_page(self, widget, page, page_num): - if self.interface.on_select: - self.interface.on_select( - self.interface, option=self.interface.content[page_num] - ) + self.interface.on_select(None) def add_content(self, index, text, widget): sub_container = TogaContainer() sub_container.content = widget + self.sub_containers.insert(index, sub_container) self.native.insert_page(sub_container, Gtk.Label(label=text), index) - # Tabs aren't visible by default; # tell the notebook to show all content. self.native.show_all() - def set_on_select(self, handler): - # No special handling required - pass - def remove_content(self, index): - if index == self.native.get_current_page(): - # Don't allow removal of a selected tab - raise self.interface.OptionException( - "Currently selected option cannot be removed" - ) self.native.remove_page(index) + self.sub_containers[index].content = None + del self.sub_containers[index] def set_option_enabled(self, index, enabled): - self.interface.factory.not_implemented("OptionContainer.set_option_enabled()") + self.sub_containers[index].set_visible(enabled) def is_option_enabled(self, index): - self.interface.factory.not_implemented("OptionContainer.is_option_enabled()") + return self.sub_containers[index].get_visible() def set_option_text(self, index, value): tab = self.native.get_nth_page(index) diff --git a/gtk/tests_backend/widgets/optioncontainer.py b/gtk/tests_backend/widgets/optioncontainer.py new file mode 100644 index 0000000000..7a0be8dda0 --- /dev/null +++ b/gtk/tests_backend/widgets/optioncontainer.py @@ -0,0 +1,22 @@ +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class OptionContainerProbe(SimpleProbe): + native_class = Gtk.Notebook + disabled_tab_selectable = False + + def repaint_needed(self): + return ( + self.impl.sub_containers[self.native.get_current_page()].needs_redraw + or super().repaint_needed() + ) + + def select_tab(self, index): + # Can't select a tab that isn't visible. + if self.tab_enabled(index): + self.native.set_current_page(index) + + def tab_enabled(self, index): + return self.impl.sub_containers[index].get_visible() diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index f9df2af130..6c89504c0b 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -475,46 +475,46 @@ async def test_flex_widget_size(widget, probe): # Container is initially a non-flex row widget of fixed size. # Paint the background so we can easily see it against the background. widget.style.flex = 0 - widget.style.width = 100 + widget.style.width = 300 widget.style.height = 200 widget.style.background_color = CORNFLOWERBLUE - await probe.redraw("Widget should have fixed 100x200 size") + await probe.redraw("Widget should have fixed 300x200 size") # Check the initial widget size # Match isn't exact because of pixel scaling on some platforms - assert probe.width == approx(100, rel=0.01) + assert probe.width == approx(300, rel=0.01) assert probe.height == approx(200, rel=0.01) # Drop the fixed height, and make the widget flexible widget.style.flex = 1 del widget.style.height - # Widget should now be 100 pixels wide, but as tall as the container. - await probe.redraw("Widget should be 100px wide now") - assert probe.width == approx(100, rel=0.01) - assert probe.height > 300 + # Widget should now be 300 pixels wide, but as tall as the container. + await probe.redraw("Widget should be 300px wide, full height") + assert probe.width == approx(300, rel=0.01) + assert probe.height > 350 # Make the parent a COLUMN box del widget.style.width widget.parent.style.direction = COLUMN # Widget should now be the size of the container - await probe.redraw("Widget should be the size of container now") - assert probe.width > 300 - assert probe.height > 300 + await probe.redraw("Widget should be the size of container") + assert probe.width > 350 + assert probe.height > 350 # Revert to fixed height widget.style.height = 150 - await probe.redraw("Widget should be reverted to fixed height") - assert probe.width > 300 + await probe.redraw("Widget should be full width, 150px high") + assert probe.width > 350 assert probe.height == approx(150, rel=0.01) # Revert to fixed width - widget.style.width = 150 + widget.style.width = 250 await probe.redraw("Widget should be reverted to fixed width") - assert probe.width == approx(150, rel=0.01) + assert probe.width == approx(250, rel=0.01) assert probe.height == approx(150, rel=0.01) diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py new file mode 100644 index 0000000000..f651ae636a --- /dev/null +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -0,0 +1,259 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.colors import CORNFLOWERBLUE, GOLDENROD, REBECCAPURPLE, SEAGREEN +from toga.style.pack import Pack + +from ..conftest import skip_on_platforms +from .probe import get_probe +from .properties import ( # noqa: F401 + test_enable_noop, + test_flex_widget_size, + test_focus_noop, +) + + +@pytest.fixture +async def content1(): + return toga.Box( + children=[toga.Label("Box 1 content", style=Pack(flex=1))], + style=Pack(background_color=REBECCAPURPLE), + ) + + +@pytest.fixture +async def content2(): + return toga.Box( + children=[toga.Label("Box 2 content", style=Pack(flex=1))], + style=Pack(background_color=CORNFLOWERBLUE), + ) + + +@pytest.fixture +async def content3(): + return toga.Box( + children=[toga.Label("Box 3 content", style=Pack(flex=1))], + style=Pack(background_color=GOLDENROD), + ) + + +@pytest.fixture +async def content1_probe(content1): + return get_probe(content1) + + +@pytest.fixture +async def content2_probe(content2): + return get_probe(content2) + + +@pytest.fixture +async def content3_probe(content3): + return get_probe(content3) + + +@pytest.fixture +async def on_select_handler(): + return Mock() + + +@pytest.fixture +async def widget(content1, content2, content3, on_select_handler): + skip_on_platforms("android", "iOS") + return toga.OptionContainer( + content=[("Tab 1", content1), ("Tab 2", content2), ("Tab 3", content3)], + style=Pack(flex=1), + on_select=on_select_handler, + ) + + +async def test_select_tab( + widget, + probe, + on_select_handler, + content1_probe, + content2_probe, + content3_probe, +): + """Tabs of content can be selected""" + # Initially selected tab has content that is the full size of the widget + await probe.redraw("Tab 1 should be selected") + assert widget.current_tab.index == 0 + assert content1_probe.width > 500 + assert content1_probe.height > 400 + + # on_select hasn't been invoked. + on_select_handler.assert_not_called() + + # Select item 1 programmatically + widget.current_tab = "Tab 2" + await probe.redraw("Tab 2 should be selected") + + assert widget.current_tab.index == 1 + assert content2_probe.width > 500 + assert content2_probe.height > 400 + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Select item 2 in the GUI + probe.select_tab(2) + await probe.redraw("Tab 3 should be selected") + + assert widget.current_tab.index == 2 + assert content3_probe.width > 500 + assert content3_probe.height > 400 + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + +async def test_enable_tab(widget, probe, on_select_handler): + """Tabs of content can be enabled and disabled""" + # All tabs are enabled, current tab is 0 + assert widget.current_tab.index == 0 + assert widget.content[0].enabled + assert widget.content[1].enabled + + # on_select hasn't been invoked. + on_select_handler.assert_not_called() + + # Disable item 1 + widget.content[1].enabled = False + await probe.redraw("Tab 2 should be disabled") + + assert widget.content[0].enabled + assert not widget.content[1].enabled + assert probe.tab_enabled(0) + assert not probe.tab_enabled(1) + + # Try to select a disabled tab + probe.select_tab(1) + await probe.redraw("Try to select tab 2") + + if probe.disabled_tab_selectable: + assert widget.current_tab.index == 1 + on_select_handler.assert_called_once_with(widget) + widget.current_tab = 0 + on_select_handler.reset_mock() + else: + assert widget.current_tab.index == 0 + on_select_handler.assert_not_called() + + assert widget.content[0].enabled + assert not widget.content[1].enabled + + # Disable item 1 again, even though it's disabled + widget.content[1].enabled = False + await probe.redraw("Tab 2 should still be disabled") + + assert widget.content[0].enabled + assert not widget.content[1].enabled + + # Select tab 3, which is index 2 in the widget's content; but on platforms + # where disabling a tab means hiding the tab completely, it will be *visual* + # index 1, but content index 2. Make sure the indices are all correct. + widget.current_tab = 2 + await probe.redraw("Tab 3 should be selected") + + assert widget.current_tab.index == 2 + assert widget.current_tab.text == "Tab 3" + + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Enable item 1 + widget.content[1].enabled = True + await probe.redraw("Tab 2 should be enabled") + + assert widget.content[0].enabled + assert widget.content[1].enabled + assert probe.tab_enabled(0) + assert probe.tab_enabled(1) + + # Try to select tab 1 + probe.select_tab(1) + await probe.redraw("Tab 1 should be selected") + + assert widget.current_tab.index == 1 + assert widget.content[0].enabled + assert widget.content[1].enabled + + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Enable item 1 again, even though it's enabled + widget.content[1].enabled = True + await probe.redraw("Tab 2 should still be enabled") + + assert widget.content[0].enabled + assert widget.content[1].enabled + + +async def test_change_content( + widget, + probe, + content2, + content2_probe, + on_select_handler, +): + """Tabs of content can be added and removed""" + + # Add new content in an enabled state + new_box = toga.Box( + children=[toga.Label("New content", style=Pack(flex=1))], + style=Pack(background_color=SEAGREEN), + ) + new_probe = get_probe(new_box) + + widget.content.insert(1, "New tab", new_box, enabled=False) + await probe.redraw("New tab has been added disabled") + + assert len(widget.content) == 4 + assert widget.content[1].text == "New tab" + assert not widget.content[1].enabled + + # Enable the new content and select it + widget.content[1].enabled = True + widget.current_tab = "New tab" + await probe.redraw("New tab has been enabled and selected") + + assert widget.current_tab.index == 1 + assert widget.current_tab.text == "New tab" + assert new_probe.width > 500 + assert new_probe.height > 400 + + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Change the title of Tab 2 + widget.content["Tab 2"].text = "New 2" + await probe.redraw("Tab 2 has been renamed") + + assert widget.content[2].text == "New 2" + + # Remove Tab 2 + widget.content.remove("New 2") + await probe.redraw("Tab 2 has been removed") + assert len(widget.content) == 3 + + # Add tab 2 back in at the end with a new title + widget.content.append("New Tab 2", content2) + await probe.redraw("Tab 2 has been added with a new title") + + widget.current_tab = "New Tab 2" + await probe.redraw("Revised tab 2 has been selected") + + assert widget.current_tab.index == 3 + assert widget.current_tab.text == "New Tab 2" + assert content2_probe.width > 500 + assert content2_probe.height > 400 + + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() diff --git a/winforms/src/toga_winforms/widgets/base.py b/winforms/src/toga_winforms/widgets/base.py index 8ac0e902f8..ac0e0dc443 100644 --- a/winforms/src/toga_winforms/widgets/base.py +++ b/winforms/src/toga_winforms/widgets/base.py @@ -4,7 +4,20 @@ from toga_winforms.libs import Color, Point, Size, SystemColors -class Widget: +class Scalable: + def init_scale(self, native): + self.scale = native.CreateGraphics().DpiX / 96 + + # Convert CSS pixels to native pixels + def scale_in(self, value): + return int(round(value * self.scale)) + + # Convert native pixels to CSS pixels + def scale_out(self, value): + return int(round(value / self.scale)) + + +class Widget(Scalable): # In some widgets, attempting to set a background color with any alpha value other # than 1 raises "System.ArgumentException: Control does not support transparent # background colors". Those widgets should set this attribute to False. @@ -17,7 +30,7 @@ def __init__(self, interface): self._container = None self.native = None self.create() - self.scale = self.native.CreateGraphics().DpiX / 96 + self.init_scale(self.native) self.interface.style.reapply() @abstractmethod @@ -54,14 +67,6 @@ def container(self, container): def viewport(self): return self._container - # Convert CSS pixels to native pixels - def scale_in(self, value): - return int(round(value * self.scale)) - - # Convert native pixels to CSS pixels - def scale_out(self, value): - return int(round(value / self.scale)) - def get_tab_index(self): return self.native.TabIndex diff --git a/winforms/src/toga_winforms/widgets/optioncontainer.py b/winforms/src/toga_winforms/widgets/optioncontainer.py index 38a0c0fbb3..6819293212 100644 --- a/winforms/src/toga_winforms/widgets/optioncontainer.py +++ b/winforms/src/toga_winforms/widgets/optioncontainer.py @@ -1,41 +1,35 @@ -from toga_winforms.container import Container -from toga_winforms.libs import WinForms +from System.Windows.Forms import TabControl, TabPage +from ..container import Container from .base import Widget class OptionContainer(Widget): def create(self): - self.native = WinForms.TabControl() + self.native = TabControl() self.native.Selected += self.winforms_selected + self.panels = [] def add_content(self, index, text, widget): - widget.viewport = Container(self.native) - widget.frame = self - # Add all children to the content widget. - for child in widget.interface.children: - child._impl.container = widget + page = TabPage(text) + self.native.TabPages.Insert(index, page) - item = WinForms.TabPage() - item.Text = text + panel = Container(page) + self.panels.insert(index, panel) + panel.set_content(widget) - # Enable AutoSize on the container to fill - # the available space in the OptionContainer. - widget.AutoSize = True - - item.Controls.Add(widget.native) - if index < self.native.TabPages.Count: - self.native.TabPages.Insert(index, item) - else: - self.native.TabPages.Add(item) + # ClientSize is set correctly for a newly-added tab, but is only updated on + # resize for the selected tab. And when the selection changes, the + # newly-selected tab's ClientSize is not updated until some time after the + # Selected event fires. + self.resize_content(panel) + page.ClientSizeChanged += lambda sender, event: self.resize_content(panel) def remove_content(self, index): - tab_page = self.native.TabPages[index] - self.native.TabPages.Remove(self.native.TabPages[index]) - tab_page.Dispose() + panel = self.panels.pop(index) + panel.clear_content() - def set_on_select(self, handler): - pass + self.native.TabPages.RemoveAt(index) def set_option_enabled(self, index, enabled): """Winforms documentation states that Enabled is not meaningful for this @@ -61,7 +55,8 @@ def set_current_tab_index(self, current_tab_index): self.native.SelectedIndex = current_tab_index def winforms_selected(self, sender, event): - if self.interface.on_select: - self.interface.on_select( - self.interface, option=self.interface.content[self.native.SelectedIndex] - ) + self.interface.on_select(None) + + def resize_content(self, panel): + size = panel.native_parent.ClientSize + panel.resize_content(size.Width, size.Height) diff --git a/winforms/src/toga_winforms/widgets/splitcontainer.py b/winforms/src/toga_winforms/widgets/splitcontainer.py index bc098dbb9a..6264f56273 100644 --- a/winforms/src/toga_winforms/widgets/splitcontainer.py +++ b/winforms/src/toga_winforms/widgets/splitcontainer.py @@ -10,12 +10,6 @@ from .base import Widget -class SplitPanel(Container): - def resize_content(self, **kwargs): - size = self.native_parent.ClientSize - super().resize_content(size.Width, size.Height, **kwargs) - - class SplitContainer(Widget): def create(self): self.native = NativeSplitContainer() @@ -25,7 +19,7 @@ def create(self): # (at least on Windows 10), which would make the split bar invisible. self.native.BorderStyle = BorderStyle.Fixed3D - self.panels = (SplitPanel(self.native.Panel1), SplitPanel(self.native.Panel2)) + self.panels = (Container(self.native.Panel1), Container(self.native.Panel2)) self.pending_position = None def set_bounds(self, x, y, width, height): @@ -81,4 +75,5 @@ def get_max_position(self): def resize_content(self, **kwargs): for panel in self.panels: - panel.resize_content(**kwargs) + size = panel.native_parent.ClientSize + panel.resize_content(size.Width, size.Height, **kwargs) diff --git a/winforms/src/toga_winforms/window.py b/winforms/src/toga_winforms/window.py index 422a1a4df8..a504036d98 100644 --- a/winforms/src/toga_winforms/window.py +++ b/winforms/src/toga_winforms/window.py @@ -2,9 +2,10 @@ from .container import Container, MinimumContainer from .libs import Point, Size, WinForms +from .widgets.base import Scalable -class Window(Container): +class Window(Container, Scalable): def __init__(self, interface, title, position, size): self.interface = interface self.interface._impl = self @@ -22,6 +23,7 @@ def __init__(self, interface, title, position, size): self.native._impl = self self.native.FormClosing += self.winforms_FormClosing super().__init__(self.native) + self.init_scale(self.native) self.native.MinimizeBox = self.native.interface.minimizable @@ -73,16 +75,18 @@ def create_toolbar(self): self.resize_content() def get_position(self): - return self.native.Location.X, self.native.Location.Y + location = self.native.Location + return tuple(map(self.scale_out, (location.X, location.Y))) def set_position(self, position): - self.native.Location = Point(*position) + self.native.Location = Point(*map(self.scale_in, position)) def get_size(self): - return self.native.ClientSize.Width, self.native.ClientSize.Height + size = self.native.ClientSize + return tuple(map(self.scale_out, (size.Width, size.Height))) def set_size(self, size): - self.native.ClientSize = Size(*size) + self.native.ClientSize = Size(*map(self.scale_in, size)) def set_app(self, app): if app is None: diff --git a/winforms/tests_backend/widgets/optioncontainer.py b/winforms/tests_backend/widgets/optioncontainer.py new file mode 100644 index 0000000000..e2d6b5900a --- /dev/null +++ b/winforms/tests_backend/widgets/optioncontainer.py @@ -0,0 +1,14 @@ +from System.Windows.Forms import TabControl + +from .base import SimpleProbe + + +class OptionContainerProbe(SimpleProbe): + native_class = TabControl + disabled_tab_selectable = True + + def select_tab(self, index): + self.native.SelectedIndex = index + + def tab_enabled(self, index): + return self.native.TabPages[index].Enabled