From 932efb020978063e3e208a9dd9526fc05c9ddde5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 19 Jun 2023 13:12:17 +0800 Subject: [PATCH 01/11] Update docs and core API for OptionContainer. --- changes/1996.feature.rst | 1 + changes/1996.removal.1.rst | 1 + changes/1996.removal.2.rst | 1 + core/src/toga/widgets/optioncontainer.py | 494 ++++++------------ core/tests/test_deprecated_factory.py | 6 - .../api/containers/optioncontainer.rst | 69 ++- docs/reference/api/index.rst | 2 +- docs/reference/data/widgets_by_platform.csv | 2 +- docs/reference/images/OptionContainer.jpeg | Bin 17038 -> 0 bytes docs/reference/images/OptionContainer.png | Bin 0 -> 15182 bytes .../optioncontainer/optioncontainer/app.py | 6 +- 11 files changed, 235 insertions(+), 347 deletions(-) create mode 100644 changes/1996.feature.rst create mode 100644 changes/1996.removal.1.rst create mode 100644 changes/1996.removal.2.rst delete mode 100644 docs/reference/images/OptionContainer.jpeg create mode 100644 docs/reference/images/OptionContainer.png 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..61f5b0b8c0 --- /dev/null +++ b/changes/1996.removal.2.rst @@ -0,0 +1 @@ +``OptionContainer.add()`` has been renamed ``OptionContainer.append()`` for consistency with List APIs. diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index c118cb0d30..b772d824ec 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -1,131 +1,89 @@ -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) + self._interface._impl.set_option_enabled(self.index, bool(enabled)) @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 - - @label.setter - def label(self, label): - warnings.warn( - "OptionItem.label has been renamed OptionItem.text", DeprecationWarning - ) - self.text = label + if value is None: + raise ValueError("Item text cannot be None") - ###################################################################### - # End backwards compatibility. - ###################################################################### + text = str(value) + if not text: + raise ValueError("Item text cannot be blank") - -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): self.interface = 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, item: int | str | OptionItem) -> OptionItem: + """Obtain a specific tab of content.""" + return self._options[self.index(item)] - # 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): + """Remove the specified tab of content. - def __getitem__(self, index): - return self._options[index] + The currently selected item cannot be deleted. + """ + self.remove(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 +91,70 @@ def __delitem__(self, index): for option in self._options[index:]: option._index -= 1 + # Refresh the widget + self.interface.refresh() + def __iter__(self): + """Obtain an interator over all tabs in the OptionContainer.""" return iter(self._options) - 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 specifier + + Raises :any:`ValueError` if no tab matching the value can be found. + + :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. + """ + 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) @@ -270,81 +176,78 @@ def _insert(self, index, text, widget, enabled=True): 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.widgets.base.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.widgets.base.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.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: + """The currently selected item of content. + + When setting the current item, you can use: + + * The integer index of the item + + * An OptionItem reference + + * The string label of the item. The first item whose label matches + will be selected. + """ + return self._content[self._impl.get_current_tab_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) + self._impl.set_current_tab_index(index) @Widget.app.setter def app(self, app): @@ -364,83 +267,30 @@ 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. + def append(self, text: str, widget: Widget): + """Append a new tab of content to the OptionContainer. - Args: - text (str): The text for the option. - widget (:class:`~toga.widgets.base.Widget`): The widget to add to the option. + :param text: The text label for the new tab + :param widget: The content widget to use for the new tab. """ - ################################################################## - # 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. + def insert(self, index: int | str | OptionItem, text: str, widget: Widget): + """Insert a new tab of content to the OptionContainer at the specified index. - Args: - index (int): Index for the option. - text (str): The text for the option. - widget (:class:`~toga.widgets.base.Widget`): The widget to add to the option. + :param index: The index where the new item should be inserted (or a specifier + that can be converted into an index). + :param text: The text label for the new tab. + :param widget: The content widget to use for the new tab. """ - widget.app = self.app - widget.window = self.window - self._content.insert(index, text, widget) - def remove(self, index): - del self._content[index] + def remove(self, item: int | str | OptionItem): + """Remove a tab of content from the OptionContainer. + + :param item: The tab of content to remove. + """ + self._content.remove(item) def refresh_sublayouts(self): """Refresh the layout and appearance of this widget.""" @@ -448,20 +298,10 @@ def refresh_sublayouts(self): 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 4938111041..7d333d659f 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -99,12 +99,6 @@ def test_image_view_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_selection_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Selection(factory=self.factory) diff --git a/docs/reference/api/containers/optioncontainer.rst b/docs/reference/api/containers/optioncontainer.rst index a5b45d9500..b19cd94a21 100644 --- a/docs/reference/api/containers/optioncontainer.rst +++ b/docs/reference/api/containers/optioncontainer.rst @@ -1,6 +1,12 @@ Option Container ================ +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 @@ Option Container :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,66 @@ Usage import toga - container = toga.OptionContainer() + pizza = toga.Box() + pasta = toga.Box() + + container = toga.OptionContainer( + content=[("Pizza", first), ("Pasta", second)] + ) + + # Add another tab of content + salad = toga.Box() + container.add("Salad", third) + +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: + + .. code-block:: python - table = toga.Table(['Hello', 'World']) - tree = toga.Tree(['Navigate']) + # Make the second tab in the container new content + container.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] - container.add('Table', table) - container.add('Tree', tree) +* The string label of the tab: + + .. code-block:: python + + # Insert content at the index currently occupied by a tab labeled "Pasta" + container.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.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.widgets.optioncontainer.OptionContainer + :members: + :undoc-members: app, window + +.. autoclass:: toga.widgets.optioncontainer.OptionList :members: :undoc-members: + +.. autoclass:: toga.widgets.optioncontainer.OptionItem + :members: + :undoc-members: refresh diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 7a6286a271..2bb4bf7880 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -62,7 +62,7 @@ Layout widgets the container, with overflow controlled by scroll bars. :doc:`SplitContainer ` A container that divides an area into multiple 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/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index f9185de200..914ecbd461 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.widgets.base.Widget`,The base widget,|y|,|y| Box,Layout Widget,:class:`~toga.widgets.box.Box`,Container for components,|y|,|y|,|y|,|y|,|y|,|b| ScrollContainer,Layout Widget,:class:`~toga.widgets.scrollcontainer.ScrollContainer`,A container that can display a layout larger that the area of the container, with overflow controlled by scroll bars.,|b|,|b|,|b|,|b|,|b|, SplitContainer,Layout Widget,:class:`~toga.widgets.splitcontainer.SplitContainer`,A container that divides an area into multiple panels with a movable border.,|b|,|b|,|b|,,, -OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,Option Container,|b|,|b|,|b|,,, +OptionContainer,Layout Widget,:class:`~toga.widgets.optioncontainer.OptionContainer`,A container that can display multiple labeled tabs of content.,|b|,|b|,|b|,,, 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.fonts.Font`,Fonts,|b|,|b|,|b|,|b|,|b|, Command,Resource,:class:`~toga.command.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 eb5c18258bd1cc72b78a07185e3d65ef4bfa579d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17038 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 diff --git a/docs/reference/images/OptionContainer.png b/docs/reference/images/OptionContainer.png new file mode 100644 index 0000000000000000000000000000000000000000..13c1f6f4f9ccb4d0d3610e4bbda1ac4ac55bc4f8 GIT binary patch literal 15182 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 Date: Mon, 19 Jun 2023 14:26:18 +0800 Subject: [PATCH 02/11] Core tests converted to Pytest, with 100% coverage. --- changes/1996.removal.2.rst | 2 +- core/src/toga/widgets/base.py | 5 - core/src/toga/widgets/optioncontainer.py | 54 +- core/tests/widgets/test_optioncontainer.py | 719 +++++++++++------- demo/toga_demo/app.py | 10 +- .../src/toga_dummy/widgets/optioncontainer.py | 28 +- .../optioncontainer/optioncontainer/app.py | 6 +- 7 files changed, 466 insertions(+), 358 deletions(-) diff --git a/changes/1996.removal.2.rst b/changes/1996.removal.2.rst index 61f5b0b8c0..7b1b2a7c64 100644 --- a/changes/1996.removal.2.rst +++ b/changes/1996.removal.2.rst @@ -1 +1 @@ -``OptionContainer.add()`` has been renamed ``OptionContainer.append()`` for consistency with List APIs. +``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/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py index b0d2f8e1e5..d8033bd715 100644 --- a/core/src/toga/widgets/base.py +++ b/core/src/toga/widgets/base.py @@ -262,16 +262,11 @@ def refresh(self): # 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): - for child in self.children: - child.refresh_sublayouts() - def focus(self): """Give this widget the input focus. diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index b772d824ec..35dabac593 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -51,9 +51,6 @@ def content(self) -> Widget: """The content widget displayed in this tab of the OptionContainer.""" return self._content - def refresh(self): - self._content.refresh() - class OptionList: def __init__(self, interface): @@ -171,7 +168,7 @@ def insert( # 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 @@ -203,7 +200,7 @@ def __init__( if content: for text, widget in content: - self.append(text, widget) + self.content.append(text, widget) self.on_select = on_select @@ -231,18 +228,11 @@ def content(self) -> OptionList: @property def current_tab(self) -> OptionItem: - """The currently selected item of content. - - When setting the current item, you can use: - - * The integer index of the item - - * An OptionItem reference - - * The string label of the item. The first item whose label matches - will be selected. - """ - return self._content[self._impl.get_current_tab_index()] + """The currently selected tab of content.""" + index = self._impl.get_current_tab_index() + if index is None: + return None + return self._content[index] @current_tab.setter def current_tab(self, value): @@ -267,36 +257,6 @@ def window(self, window): for item in self._content: item._content.window = window - def append(self, text: str, widget: Widget): - """Append 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._content.append(text, widget) - - def insert(self, index: int | str | OptionItem, text: str, widget: Widget): - """Insert a new tab of content to the OptionContainer at the specified index. - - :param index: The index where the new item should be inserted (or a specifier - that can be converted into an index). - :param text: The text label for the new tab. - :param widget: The content widget to use for the new tab. - """ - self._content.insert(index, text, widget) - - def remove(self, item: int | str | OptionItem): - """Remove a tab of content from the OptionContainer. - - :param item: The tab of content to remove. - """ - self._content.remove(item) - - def refresh_sublayouts(self): - """Refresh the layout and appearance of this widget.""" - for widget in self._content: - widget.refresh() - @property def on_select(self) -> callable: """The callback to invoke when a new tab of content is selected.""" diff --git a/core/tests/widgets/test_optioncontainer.py b/core/tests/widgets/test_optioncontainer.py index 0dbef38fde..0af03e070c 100644 --- a/core/tests/widgets/test_optioncontainer.py +++ b/core/tests/widgets/test_optioncontainer.py @@ -1,284 +1,439 @@ -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 + + +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" + + # Programatically 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) 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/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 e40a597b55..cc035cd488 100644 --- a/examples/optioncontainer/optioncontainer/app.py +++ b/examples/optioncontainer/optioncontainer/app.py @@ -13,9 +13,9 @@ def _create_options(self): 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.optioncontainer.content.append("Option 0", box0) + self.optioncontainer.content.append("Option 1", box1) + self.optioncontainer.content.append("Option 2", box2) self._refresh_select() def _refresh_select(self): From ec6176dbc35967753abbd1bd67599de500db7162 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 20 Jun 2023 11:57:16 +0800 Subject: [PATCH 03/11] Cocoa implementation to 100% coverage. --- changes/1996.removal.3.rst | 1 + .../src/toga_cocoa/widgets/optioncontainer.py | 106 ++++---- .../tests_backend/widgets/optioncontainer.py | 40 +++ core/src/toga/widgets/optioncontainer.py | 11 +- core/tests/widgets/test_optioncontainer.py | 32 +++ .../optioncontainer/optioncontainer/app.py | 19 +- testbed/tests/widgets/properties.py | 28 +-- testbed/tests/widgets/test_optioncontainer.py | 236 ++++++++++++++++++ 8 files changed, 398 insertions(+), 75 deletions(-) create mode 100644 changes/1996.removal.3.rst create mode 100644 cocoa/tests_backend/widgets/optioncontainer.py create mode 100644 testbed/tests/widgets/test_optioncontainer.py 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..92c4b8c840 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -1,83 +1,88 @@ -from rubicon.objc import objc_method +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_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,14 +90,9 @@ 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) @@ -114,5 +114,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..504ddb69d6 --- /dev/null +++ b/cocoa/tests_backend/widgets/optioncontainer.py @@ -0,0 +1,40 @@ +from toga_cocoa.libs import NSTabView + +from .base import SimpleProbe + + +class OptionContainerProbe(SimpleProbe): + native_class = NSTabView + + # 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 + 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) diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 35dabac593..aa4dfc5c43 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -22,8 +22,12 @@ def enabled(self) -> bool: return self._interface._impl.is_option_enabled(self.index) @enabled.setter - def enabled(self, enabled): - self._interface._impl.set_option_enabled(self.index, bool(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) -> str: @@ -237,6 +241,9 @@ def current_tab(self) -> OptionItem: @current_tab.setter 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 diff --git a/core/tests/widgets/test_optioncontainer.py b/core/tests/widgets/test_optioncontainer.py index 0af03e070c..349f0d0e2c 100644 --- a/core/tests/widgets/test_optioncontainer.py +++ b/core/tests/widgets/test_optioncontainer.py @@ -179,6 +179,24 @@ def test_item_enabled(optioncontainer, value, expected): 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 @@ -437,3 +455,17 @@ def test_current_tab(optioncontainer, index, on_select_handler): # 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/examples/optioncontainer/optioncontainer/app.py b/examples/optioncontainer/optioncontainer/app.py index cc035cd488..672b6e9f1b 100644 --- a/examples/optioncontainer/optioncontainer/app.py +++ b/examples/optioncontainer/optioncontainer/app.py @@ -5,9 +5,9 @@ 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)) + 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]) @@ -39,7 +39,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 +50,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 +58,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 +72,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): diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 9c46a7e4c8..3883e641d5 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -472,46 +472,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 > 400 # 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 > 500 + assert probe.height > 400 # 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 > 500 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..ecff4a3387 --- /dev/null +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -0,0 +1,236 @@ +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 + 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 programatically + 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 + + # Try to select tab 1 + probe.select_tab(1) + await probe.redraw("Tab 1 should still be selected") + + assert widget.current_tab.index == 0 + assert widget.content[0].enabled + assert not widget.content[1].enabled + + # on_select hasn't been invoked. + on_select_handler.assert_not_called() + + # 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 + + # 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 + + # 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() From f8eb53f1680201e1a1fb5ccc0adf8d6962c14993 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 20 Jun 2023 13:09:30 +0800 Subject: [PATCH 04/11] GTK optioncontainer coverage to 100%. --- gtk/src/toga_gtk/widgets/optioncontainer.py | 24 ++++++------------- gtk/tests_backend/widgets/optioncontainer.py | 18 ++++++++++++++ testbed/tests/widgets/test_optioncontainer.py | 13 ++++++++++ 3 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 gtk/tests_backend/widgets/optioncontainer.py 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..ae1610c203 --- /dev/null +++ b/gtk/tests_backend/widgets/optioncontainer.py @@ -0,0 +1,18 @@ +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class OptionContainerProbe(SimpleProbe): + native_class = Gtk.Notebook + + 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.impl.sub_containers[index].get_visible(): + self.native.set_current_page(index) diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index ecff4a3387..948dcb2b8c 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -144,6 +144,19 @@ async def test_enable_tab(widget, probe, on_select_handler): assert widget.content[0].enabled assert not widget.content[1].enabled + # Select tab 3, which is index 2 in the widget's contentt; 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") From fbdf363c56b5e9169aaa32b0dee6b982d0801e9a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 20 Jun 2023 13:19:11 +0800 Subject: [PATCH 05/11] Introduce a small explicit delay to work around intermittent test failure. --- testbed/tests/widgets/test_textinput.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 61fb95c1b6..ab398f23ee 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -111,7 +111,7 @@ async def test_on_change_user(widget, probe, on_change): for count, char in enumerate("Hello world", start=1): await probe.type_character(char) - await probe.redraw(f"Typed {char!r}") + await probe.redraw(f"Typed {char!r}", delay=0.02) # The number of events equals the number of characters typed. assert on_change.mock_calls == [call(widget)] * count From b6d05f9aa03af742fd97d48a2a18f5df4ebf0455 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 20 Jun 2023 13:51:19 +0800 Subject: [PATCH 06/11] Lower the horizontal limit that identifies full width. --- testbed/tests/widgets/properties.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 3883e641d5..5431f23df7 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -489,7 +489,7 @@ async def test_flex_widget_size(widget, probe): # 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 > 400 + assert probe.height > 350 # Make the parent a COLUMN box del widget.style.width @@ -497,14 +497,14 @@ async def test_flex_widget_size(widget, probe): # Widget should now be the size of the container await probe.redraw("Widget should be the size of container") - assert probe.width > 500 - assert probe.height > 400 + assert probe.width > 350 + assert probe.height > 350 # Revert to fixed height widget.style.height = 150 await probe.redraw("Widget should be full width, 150px high") - assert probe.width > 500 + assert probe.width > 350 assert probe.height == approx(150, rel=0.01) # Revert to fixed width From 76e888796724db2d6e24f543a2d990408bb8c213 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 1 Aug 2023 11:49:57 +0800 Subject: [PATCH 07/11] Correct some spelling errors. --- core/src/toga/widgets/optioncontainer.py | 2 +- core/tests/test_handlers.py | 2 +- core/tests/widgets/test_optioncontainer.py | 2 +- testbed/tests/widgets/test_optioncontainer.py | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 4a770e4ef1..96b107aae5 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -96,7 +96,7 @@ def remove(self, index: int | str | OptionItem): self.interface.refresh() def __iter__(self): - """Obtain an interator over all tabs in the OptionContainer.""" + """Obtain an iterator over all tabs in the OptionContainer.""" return iter(self._options) def __len__(self) -> int: diff --git a/core/tests/test_handlers.py b/core/tests/test_handlers.py index fc3b147d02..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 erro""" + """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 349f0d0e2c..505f34d64c 100644 --- a/core/tests/widgets/test_optioncontainer.py +++ b/core/tests/widgets/test_optioncontainer.py @@ -446,7 +446,7 @@ def test_current_tab(optioncontainer, index, on_select_handler): assert optioncontainer.current_tab.index == 0 assert optioncontainer.current_tab.text == "Item 1" - # Programatically select item 2 + # Programmatically select item 2 optioncontainer.current_tab = index # Current tab values have changed diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index 948dcb2b8c..c2567bb282 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -79,6 +79,7 @@ async def test_select_tab( ): """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 @@ -86,7 +87,7 @@ async def test_select_tab( # on_select hasn't been invoked. on_select_handler.assert_not_called() - # Select item 1 programatically + # Select item 1 programmatically widget.current_tab = "Tab 2" await probe.redraw("Tab 2 should be selected") From 125ddf24cf6add31fbe8fbac7be86c21a3ab904f Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 9 Aug 2023 13:53:21 +0100 Subject: [PATCH 08/11] Documentation fixes --- core/src/toga/widgets/optioncontainer.py | 21 ++++++++---------- docs/conf.py | 4 ++++ docs/reference/api/containers/box.rst | 2 -- .../api/containers/optioncontainer.rst | 22 ++++++++----------- .../api/containers/scrollcontainer.rst | 2 -- .../api/containers/splitcontainer.rst | 2 -- docs/reference/api/widgets/widget.rst | 2 -- docs/reference/data/widgets_by_platform.csv | 2 +- 8 files changed, 23 insertions(+), 34 deletions(-) diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 96b107aae5..cc8a71838c 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -65,15 +65,12 @@ def __repr__(self): items = ", ".join(repr(option.text) for option in self) return f"" - def __getitem__(self, item: int | str | OptionItem) -> OptionItem: + def __getitem__(self, index: int | str | OptionItem) -> OptionItem: """Obtain a specific tab of content.""" - return self._options[self.index(item)] + return self._options[self.index(index)] def __delitem__(self, index: int | str | OptionItem): - """Remove the specified tab of content. - - The currently selected item cannot be deleted. - """ + """Same as :any:`remove`.""" self.remove(index) def remove(self, index: int | str | OptionItem): @@ -95,16 +92,12 @@ def remove(self, index: int | str | OptionItem): # Refresh the widget self.interface.refresh() - def __iter__(self): - """Obtain an iterator over all tabs in the OptionContainer.""" - return iter(self._options) - def __len__(self) -> int: """The number of tabs of content in the OptionContainer.""" return len(self._options) def index(self, value: str | int | OptionItem): - """Find the index of the tab that matches the given specifier + """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; @@ -231,7 +224,11 @@ def content(self) -> OptionList: @property def current_tab(self) -> OptionItem: - """The currently selected tab of content.""" + """The currently selected tab of content. + + The getter of this property always returns an ``OptionItem``. The setter also + accepts an ``int`` index, or a ``str`` label. + """ index = self._impl.get_current_tab_index() if index is None: return None 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 dc56a26323..d31cad01a6 100644 --- a/docs/reference/api/containers/optioncontainer.rst +++ b/docs/reference/api/containers/optioncontainer.rst @@ -26,12 +26,12 @@ Usage pasta = toga.Box() container = toga.OptionContainer( - content=[("Pizza", first), ("Pasta", second)] + content=[("Pizza", pizza), ("Pasta", pasta)] ) # Add another tab of content salad = toga.Box() - container.add("Salad", third) + container.content.append("Salad", salad) When retrieving or deleting items, or when specifying the currently selected item, you can specify an item using: @@ -40,8 +40,8 @@ currently selected item, you can specify an item using: .. code-block:: python - # Make the second tab in the container new content - container.insert(1, "Soup", toga.Box()) + # 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 @@ -51,8 +51,8 @@ currently selected item, you can specify an item using: .. code-block:: python - # Insert content at the index currently occupied by a tab labeled "Pasta" - container.insert("Pasta", "Soup", toga.Box()) + # 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" @@ -65,7 +65,7 @@ currently selected item, you can specify an item using: # Get a reference to the "Pasta" tab pasta_tab = container.content["Pasta"] # Insert content at the index currently occupied by the pasta tab - container.insert(pasta_tab, "Soup", toga.Box()) + 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 @@ -75,13 +75,9 @@ Reference --------- .. autoclass:: toga.OptionContainer - :members: - :undoc-members: app, window + :exclude-members: app, window .. autoclass:: toga.widgets.optioncontainer.OptionList - :members: - :undoc-members: + :special-members: __getitem__, __delitem__ .. autoclass:: toga.widgets.optioncontainer.OptionItem - :members: - :undoc-members: refresh 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/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 c2faab48df..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`,A container that can display multiple labeled tabs of content.,|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|, From d4b62099fedd3f132da43e47a6d7afd9f2f73b29 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 9 Aug 2023 23:07:45 +0100 Subject: [PATCH 09/11] Winforms OptionContainer to 100% --- .../tests_backend/widgets/optioncontainer.py | 5 ++ core/src/toga/widgets/optioncontainer.py | 7 ++- .../optioncontainer/optioncontainer/app.py | 29 ++++++----- gtk/tests_backend/widgets/optioncontainer.py | 6 ++- testbed/tests/widgets/test_optioncontainer.py | 23 ++++++--- winforms/src/toga_winforms/widgets/base.py | 25 +++++---- .../toga_winforms/widgets/optioncontainer.py | 51 +++++++++---------- .../toga_winforms/widgets/splitcontainer.py | 11 ++-- winforms/src/toga_winforms/window.py | 14 +++-- .../tests_backend/widgets/optioncontainer.py | 14 +++++ 10 files changed, 109 insertions(+), 76 deletions(-) create mode 100644 winforms/tests_backend/widgets/optioncontainer.py diff --git a/cocoa/tests_backend/widgets/optioncontainer.py b/cocoa/tests_backend/widgets/optioncontainer.py index 504ddb69d6..64ef6cf95f 100644 --- a/cocoa/tests_backend/widgets/optioncontainer.py +++ b/cocoa/tests_backend/widgets/optioncontainer.py @@ -5,6 +5,7 @@ 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 @@ -38,3 +39,7 @@ def height(self): def select_tab(self, index): self.native.selectTabViewItemAtIndex(index) + + def tab_enabled(self, index): + # There appears to be no public method for this. + return self.native.tabViewItemAtIndex(index)._isTabEnabled() diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index cc8a71838c..0344f9e1f5 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -223,11 +223,10 @@ def content(self) -> OptionList: return self._content @property - def current_tab(self) -> OptionItem: - """The currently selected tab of content. + def current_tab(self) -> OptionItem | None: + """The currently selected tab of content, or ``None`` if there are no tabs. - The getter of this property always returns an ``OptionItem``. The setter also - accepts an ``int`` index, or a ``str`` label. + 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: diff --git a/examples/optioncontainer/optioncontainer/app.py b/examples/optioncontainer/optioncontainer/app.py index 672b6e9f1b..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.content.append("Option 0", box0) - self.optioncontainer.content.append("Option 1", box1) - self.optioncontainer.content.append("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): diff --git a/gtk/tests_backend/widgets/optioncontainer.py b/gtk/tests_backend/widgets/optioncontainer.py index ae1610c203..7a0be8dda0 100644 --- a/gtk/tests_backend/widgets/optioncontainer.py +++ b/gtk/tests_backend/widgets/optioncontainer.py @@ -5,6 +5,7 @@ class OptionContainerProbe(SimpleProbe): native_class = Gtk.Notebook + disabled_tab_selectable = False def repaint_needed(self): return ( @@ -14,5 +15,8 @@ def repaint_needed(self): def select_tab(self, index): # Can't select a tab that isn't visible. - if self.impl.sub_containers[index].get_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/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index c2567bb282..f651ae636a 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -126,18 +126,25 @@ async def test_enable_tab(widget, probe, on_select_handler): 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 tab 1 + # Try to select a disabled tab probe.select_tab(1) - await probe.redraw("Tab 1 should still be selected") + 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.current_tab.index == 0 assert widget.content[0].enabled assert not widget.content[1].enabled - # on_select hasn't been invoked. - on_select_handler.assert_not_called() - # Disable item 1 again, even though it's disabled widget.content[1].enabled = False await probe.redraw("Tab 2 should still be disabled") @@ -145,7 +152,7 @@ async def test_enable_tab(widget, probe, on_select_handler): assert widget.content[0].enabled assert not widget.content[1].enabled - # Select tab 3, which is index 2 in the widget's contentt; but on platforms + # 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 @@ -164,6 +171,8 @@ async def test_enable_tab(widget, probe, on_select_handler): 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) 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 From 7f222a5a20be67d93fc8e3b0393e275c8e2bc8b8 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 10 Aug 2023 07:42:15 +0800 Subject: [PATCH 10/11] Add implementation of tab_enabled for cocoa. --- cocoa/src/toga_cocoa/widgets/optioncontainer.py | 9 +++++++++ cocoa/tests_backend/widgets/optioncontainer.py | 10 +++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index 92c4b8c840..b49fb1e88b 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -12,6 +12,10 @@ 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: # Refresh the layout of the newly selected tab. @@ -94,6 +98,11 @@ def set_option_enabled(self, index, enabled): pass else: self._disabled_tabs.add(index) + + # 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. tabview._setTabEnabled(enabled) def is_option_enabled(self, index): diff --git a/cocoa/tests_backend/widgets/optioncontainer.py b/cocoa/tests_backend/widgets/optioncontainer.py index 64ef6cf95f..79c67b05b6 100644 --- a/cocoa/tests_backend/widgets/optioncontainer.py +++ b/cocoa/tests_backend/widgets/optioncontainer.py @@ -1,3 +1,5 @@ +from rubicon.objc import SEL, send_message + from toga_cocoa.libs import NSTabView from .base import SimpleProbe @@ -23,7 +25,7 @@ class OptionContainerProbe(SimpleProbe): # # 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 + # and hope for the best. LEFT_OFFSET = 7 RIGHT_OFFSET = 7 TOP_OFFSET = 6 @@ -41,5 +43,7 @@ def select_tab(self, index): self.native.selectTabViewItemAtIndex(index) def tab_enabled(self, index): - # There appears to be no public method for this. - return self.native.tabViewItemAtIndex(index)._isTabEnabled() + # _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=[]) From fc22290b9515146c298455508e75fe4fc934ce6a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 10 Aug 2023 07:53:57 +0800 Subject: [PATCH 11/11] Add protection against future removal of a private method. --- cocoa/src/toga_cocoa/widgets/optioncontainer.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index b49fb1e88b..73d669e44c 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -1,3 +1,5 @@ +import warnings + from rubicon.objc import SEL, objc_method from travertino.size import at_least @@ -102,8 +104,14 @@ def set_option_enabled(self, index, 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. - tabview._setTabEnabled(enabled) + # 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