diff --git a/android/src/toga_android/container.py b/android/src/toga_android/container.py index ae129953b9..7d35c5b02d 100644 --- a/android/src/toga_android/container.py +++ b/android/src/toga_android/container.py @@ -36,8 +36,8 @@ def clear_content(self): def resize_content(self, width, height): if (self.native_width, self.native_height) != (width, height): self.native_width, self.native_height = (width, height) - if self.interface.content: - self.interface.content.refresh() + if self.content: + self.content.interface.refresh() def refreshed(self): # We must use the correct LayoutParams class, but we don't know what that class @@ -45,7 +45,7 @@ def refreshed(self): # an option, but would probably be less safe because a subclass might change the # meaning of the (int, int) constructor. lp = self.native_content.getLayoutParams() - layout = self.interface.content.layout + layout = self.content.interface.layout lp.width = max(self.native_width, self.scale_in(layout.width)) lp.height = max(self.native_height, self.scale_in(layout.height)) self.native_content.setLayoutParams(lp) diff --git a/android/src/toga_android/factory.py b/android/src/toga_android/factory.py index 9171ea4766..a84f588b3a 100644 --- a/android/src/toga_android/factory.py +++ b/android/src/toga_android/factory.py @@ -15,6 +15,7 @@ from .widgets.label import Label from .widgets.multilinetextinput import MultilineTextInput from .widgets.numberinput import NumberInput +from .widgets.optioncontainer import OptionContainer from .widgets.passwordinput import PasswordInput from .widgets.progressbar import ProgressBar from .widgets.scrollcontainer import ScrollContainer @@ -55,7 +56,7 @@ def not_implemented(feature): "Label", "MultilineTextInput", "NumberInput", - # "OptionContainer", + "OptionContainer", "PasswordInput", "ProgressBar", "ScrollContainer", diff --git a/android/src/toga_android/icons.py b/android/src/toga_android/icons.py index f56ff48abc..8a2389c1df 100644 --- a/android/src/toga_android/icons.py +++ b/android/src/toga_android/icons.py @@ -1,4 +1,5 @@ -from android.graphics import BitmapFactory +from android.graphics import Bitmap, BitmapFactory, Rect +from android.graphics.drawable import BitmapDrawable class Icon: @@ -13,3 +14,16 @@ def __init__(self, interface, path): self.native = BitmapFactory.decodeFile(str(path)) if self.native is None: raise ValueError(f"Unable to load icon from {path}") + + def as_drawable(self, widget, size): + bitmap = Bitmap.createScaledBitmap( + self.native, + widget.scale_in(size), + widget.scale_in(size), + True, + ) + drawable = BitmapDrawable(widget.native.getContext().getResources(), bitmap) + drawable.setBounds( + Rect(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()) + ) + return drawable diff --git a/android/src/toga_android/resources/optioncontainer-tab.png b/android/src/toga_android/resources/optioncontainer-tab.png new file mode 100644 index 0000000000..2d78b96f59 Binary files /dev/null and b/android/src/toga_android/resources/optioncontainer-tab.png differ diff --git a/android/src/toga_android/widgets/button.py b/android/src/toga_android/widgets/button.py index 852d1d6e2f..a095f9b07e 100644 --- a/android/src/toga_android/widgets/button.py +++ b/android/src/toga_android/widgets/button.py @@ -1,7 +1,5 @@ from decimal import ROUND_UP -from android.graphics import Bitmap, Rect -from android.graphics.drawable import BitmapDrawable from android.view import View from android.widget import Button as A_Button from java import dynamic_proxy @@ -41,17 +39,8 @@ def get_icon(self): def set_icon(self, icon): self._icon = icon if icon: - # Scale icon to to a 48x48 CSS pixel bitmap. - bitmap = Bitmap.createScaledBitmap( - icon._impl.native, - self.scale_in(48), - self.scale_in(48), - True, - ) - drawable = BitmapDrawable(self.native.getContext().getResources(), bitmap) - drawable.setBounds( - Rect(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight()) - ) + # Scale icon to to a 48x48 CSS pixel bitmap drawable. + drawable = icon._impl.as_drawable(self, 48) else: drawable = None diff --git a/android/src/toga_android/widgets/optioncontainer.py b/android/src/toga_android/widgets/optioncontainer.py new file mode 100644 index 0000000000..8f2268c98d --- /dev/null +++ b/android/src/toga_android/widgets/optioncontainer.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import warnings +from dataclasses import dataclass + +from android.view import MenuItem +from android.widget import LinearLayout + +try: + from com.google.android.material.bottomnavigation import BottomNavigationView + from com.google.android.material.navigation import NavigationBarView +except ImportError: # pragma: no cover + # If you've got an older project that doesn't include the Material library, + # this import will fail. We can't validate that in CI, so it's marked no cover + BottomNavigationView = None + NavigationBarView = None + +from java import dynamic_proxy +from travertino.size import at_least + +import toga + +from ..container import Container +from .base import Widget + + +@dataclass +class TogaOption: + text: str + icon: toga.Icon + widget: Widget + enabled: bool = True + menu_item: MenuItem | None = None + + +if NavigationBarView is not None: # pragma: no branch + + class TogaOnItemSelectedListener( + dynamic_proxy(NavigationBarView.OnItemSelectedListener) + ): + def __init__(self, impl): + super().__init__() + self.impl = impl + + def onNavigationItemSelected(self, item): + for index, option in enumerate(self.impl.options): + if option.menu_item == item: + self.impl.set_current_tab_index(index, programmatic=False) + return True + + # You shouldn't be able to select an item that isn't isn't selectable. + return False # pragma: no cover + + +class OptionContainer(Widget, Container): + uses_icons = True + + def create(self): + if BottomNavigationView is None: # pragma: no cover + raise RuntimeError( + "Unable to import BottomNavigationView. Ensure that the Material " + "system package (com.google.android.material:material:1.11.0) " + "is listed in your app's dependencies." + ) + + self.native = LinearLayout(self._native_activity) + self.native.setOrientation(LinearLayout.VERTICAL) + + # Define layout parameters for children; expand to fill, + self.init_container(self.native) + self.native_content.setLayoutParams( + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT, + 1, # weight 1; child content should expand + ) + ) + + # Add the navigation bar + self.native_navigationview = BottomNavigationView(self._native_activity) + self.native_navigationview.setLabelVisibilityMode( + BottomNavigationView.LABEL_VISIBILITY_LABELED + ) + self.max_items = self.native_navigationview.getMaxItemCount() + + self.native.addView( + self.native_navigationview, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + 0, # weight 0; it shouldn't expand + ), + ) + + self.onItemSelectedListener = TogaOnItemSelectedListener(self) + self.native_navigationview.setOnItemSelectedListener( + self.onItemSelectedListener + ) + + self.options = [] + + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + lp = self.native.getLayoutParams() + super().resize_content( + lp.width, lp.height - self.native_navigationview.getHeight() + ) + + def purge_options(self): + for option in self.options: + option.menu_item = None + self.native_navigationview.getMenu().clear() + + def rebuld_options(self): + for index, option in enumerate(self.options): + if index < self.max_items: + option.menu_item = self.native_navigationview.getMenu().add( + 0, 0, index, option.text + ) + self.set_option_icon(index, option.icon) + self.set_option_enabled(index, option.enabled) + + def add_option(self, index, text, widget, icon=None): + # Store the details of the new option + option = TogaOption(text=text, icon=icon, widget=widget) + self.options.insert(index, option) + + # Create a menu item for the tab + if index >= self.max_items: + warnings.warn( + f"OptionContainer is limited to {self.max_items} items on " + "Android. Additional item will be ignored." + ) + option.menu_item = None + else: + if len(self.options) > self.max_items: + warnings.warn( + f"OptionContainer is limited to {self.max_items} items on " + "Android. Excess items will be ignored." + ) + last_option = self.options[self.max_items - 1] + self.native_navigationview.getMenu().removeItem( + last_option.menu_item.getItemId() + ) + last_option.menu_item = None + + # Android doesn't let you change the order index of an item after it has been + # created, which means there's no way to insert an item into an existing + # ordering. As a workaround, rebuild the entire navigation menu on every + # insertion. + self.purge_options() + self.rebuld_options() + + # If this is the only option, make sure the content is selected + if len(self.options) == 1: + self.set_current_tab_index(0) + + def remove_option(self, index): + # Android doesn't let you change the order index of an item after it has been + # created, which means there's no way to insert an item into an existing + # ordering. If an item is deleted, rebuild the entire navigation menu. + self.purge_options() + del self.options[index] + self.rebuld_options() + + def set_option_enabled(self, index, enabled): + option = self.options[index] + option.enabled = enabled + if option.menu_item: + option.menu_item.setEnabled(enabled) + + def is_option_enabled(self, index): + option = self.options[index] + if option.menu_item: + return option.menu_item.isEnabled() + else: + return option.enabled + + def set_option_text(self, index, text): + option = self.options[index] + option.text = text + if option.menu_item: + option.menu_item.setTitle(text) + + def get_option_text(self, index): + option = self.options[index] + if option.menu_item: + return option.menu_item.getTitle() + else: + return option.text + + def set_option_icon(self, index, icon): + option = self.options[index] + option.icon = icon + + if option.menu_item: + if icon is None: + icon = toga.Icon.OPTION_CONTAINER_DEFAULT_TAB_ICON + + drawable = icon._impl.as_drawable(self, 32) + option.menu_item.setIcon(drawable) + + def get_option_icon(self, index): + return self.options[index].icon + + def get_current_tab_index(self): + for index, option in enumerate(self.options): + if option.menu_item.isChecked(): + return index + # One of the tabs has to be selected + return None # pragma: no cover + + def set_current_tab_index(self, index, programmatic=True): + if index < self.max_items: + option = self.options[index] + self.set_content(option.widget) + option.widget.interface.refresh() + if programmatic: + option.menu_item.setChecked(True) + self.interface.on_select() + else: + warnings.warn("Tab is outside selectable range") + + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/android/tests_backend/widgets/optioncontainer.py b/android/tests_backend/widgets/optioncontainer.py new file mode 100644 index 0000000000..a33864a14d --- /dev/null +++ b/android/tests_backend/widgets/optioncontainer.py @@ -0,0 +1,44 @@ +from android.widget import LinearLayout +from com.google.android.material.bottomnavigation import BottomNavigationView + +from .base import SimpleProbe + + +class OptionContainerProbe(SimpleProbe): + native_class = LinearLayout + disabled_tab_selectable = False + max_tabs = 5 + + def __init__(self, widget): + super().__init__(widget) + self.native_navigationview = widget._impl.native_navigationview + assert isinstance(self.native_navigationview, BottomNavigationView) + + def select_tab(self, index): + item = self.native_navigationview.getMenu().getItem(index) + # Android will let you programmatically select a disabled tab. + if item.isEnabled(): + item.setChecked(True) + self.impl.onItemSelectedListener(item) + + def tab_enabled(self, index): + return self.native_navigationview.getMenu().getItem(index).isEnabled() + + def assert_tab_icon(self, index, expected): + actual = self.impl.options[index].icon + if expected is None: + assert actual is None + else: + assert actual.path.name == expected + assert actual._impl.path.name == f"{expected}-android.png" + + def assert_tab_content(self, index, title, enabled): + # Get the actual menu items, and sort them by their order index. + # This *should* match the actual option order. + menu_items = sorted( + [option.menu_item for option in self.impl.options if option.menu_item], + key=lambda m: m.getOrder(), + ) + + assert menu_items[index].getTitle() == title + assert menu_items[index].isEnabled() == enabled diff --git a/changes/2346.feature.rst b/changes/2346.feature.rst new file mode 100644 index 0000000000..d77e574e57 --- /dev/null +++ b/changes/2346.feature.rst @@ -0,0 +1 @@ +An OptionContainer widget was added for Android. diff --git a/cocoa/src/toga_cocoa/widgets/optioncontainer.py b/cocoa/src/toga_cocoa/widgets/optioncontainer.py index e7d2175a6c..ccf1f16c74 100644 --- a/cocoa/src/toga_cocoa/widgets/optioncontainer.py +++ b/cocoa/src/toga_cocoa/widgets/optioncontainer.py @@ -68,7 +68,7 @@ def content_refreshed(self, container): container.min_width = container.content.interface.layout.min_width container.min_height = container.content.interface.layout.min_height - def add_content(self, index, text, widget, icon): + def add_option(self, index, text, widget, icon): # Create the container for the widget container = Container(on_refresh=self.content_refreshed) container.content = widget @@ -81,7 +81,7 @@ def add_content(self, index, text, widget, icon): item.view = container.native self.native.insertTabViewItem(item, atIndex=index) - def remove_content(self, index): + def remove_option(self, index): tabview = self.native.tabViewItemAtIndex(index) self.native.removeTabViewItem(tabview) diff --git a/cocoa/tests_backend/widgets/optioncontainer.py b/cocoa/tests_backend/widgets/optioncontainer.py index 62bf19a0d3..a717acefd8 100644 --- a/cocoa/tests_backend/widgets/optioncontainer.py +++ b/cocoa/tests_backend/widgets/optioncontainer.py @@ -7,6 +7,7 @@ class OptionContainerProbe(SimpleProbe): native_class = NSTabView + max_tabs = None disabled_tab_selectable = False # 2023-06-20: This makes no sense, but here we are. If you render an NSTabView with diff --git a/core/src/toga/widgets/optioncontainer.py b/core/src/toga/widgets/optioncontainer.py index 601be4cada..edd2117d71 100644 --- a/core/src/toga/widgets/optioncontainer.py +++ b/core/src/toga/widgets/optioncontainer.py @@ -170,7 +170,7 @@ def content(self) -> Widget: """The content widget displayed in this tab of the OptionContainer.""" return self._content - def _preserve_content(self): + def _preserve_option(self): # Move the ground truth back to the OptionItem instance self._text = self.text self._icon = self.icon @@ -180,7 +180,7 @@ def _preserve_content(self): self._index = None self._interface = None - def _add_as_content(self, index, interface): + def _add_as_option(self, index, interface): text = self._text del self._text @@ -192,7 +192,7 @@ def _add_as_content(self, index, interface): self._index = index self._interface = interface - interface._impl.add_content(index, text, self.content._impl, icon) + interface._impl.add_option(index, text, self.content._impl, icon) # The option now exists on the implementation; finalize the display properties # that can't be resolved until the implementation exists. @@ -231,9 +231,9 @@ def remove(self, index: int | str | OptionItem): # Ensure that the current ground truth of the item to be deleted is preserved as # attributes on the item deleted_item = self._options[index] - deleted_item._preserve_content() + deleted_item._preserve_option() - self.interface._impl.remove_content(index) + self.interface._impl.remove_option(index) del self._options[index] # Update the index for each of the options # after the one that was removed. @@ -376,7 +376,7 @@ def insert( # Add the content to the implementation. # This will cause the native implementation to be created. - item._add_as_content(index, self.interface) + item._add_as_option(index, self.interface) class OptionContainer(Widget): diff --git a/core/tests/widgets/test_optioncontainer.py b/core/tests/widgets/test_optioncontainer.py index 91ab142cb9..d31b28949b 100644 --- a/core/tests/widgets/test_optioncontainer.py +++ b/core/tests/widgets/test_optioncontainer.py @@ -486,7 +486,7 @@ def test_delitem(optioncontainer, index): # delete item del optioncontainer.content[index] assert len(optioncontainer.content) == 3 - assert_action_performed_with(optioncontainer, "remove content", index=1) + assert_action_performed_with(optioncontainer, "remove option", index=1) # There's no item with the deleted label with pytest.raises(ValueError, match=r"No tab named 'Item 2'"): @@ -525,7 +525,7 @@ def test_item_remove(optioncontainer, index): # remove item optioncontainer.content.remove(index) assert len(optioncontainer.content) == 3 - assert_action_performed_with(optioncontainer, "remove content", index=1) + assert_action_performed_with(optioncontainer, "remove option", index=1) # There's no item with the deleted label with pytest.raises(ValueError, match=r"No tab named 'Item 2'"): @@ -561,7 +561,7 @@ def test_item_insert_item(optioncontainer): # Backend added an item and set enabled assert_action_performed_with( optioncontainer, - "add content", + "add option", index=1, text="New Tab", widget=new_content._impl, @@ -623,7 +623,7 @@ def test_item_insert_text(optioncontainer, value, expected): # Backend added an item and set enabled assert_action_performed_with( optioncontainer, - "add content", + "add option", index=1, text=expected, widget=new_content._impl, @@ -662,7 +662,7 @@ def test_item_insert_enabled(optioncontainer, enabled): # Backend added an item and set enabled assert_action_performed_with( optioncontainer, - "add content", + "add option", index=1, text="New content", widget=new_content._impl, @@ -685,7 +685,7 @@ def test_item_append(optioncontainer, enabled): optioncontainer.content.append("New content", new_content, enabled=enabled) assert_action_performed_with( - optioncontainer, "add content", index=4, widget=new_content._impl + optioncontainer, "add option", index=4, widget=new_content._impl ) assert_action_performed_with( optioncontainer, "set option enabled", index=4, value=enabled diff --git a/docs/reference/api/containers/optioncontainer.rst b/docs/reference/api/containers/optioncontainer.rst index da74c49159..f832efd013 100644 --- a/docs/reference/api/containers/optioncontainer.rst +++ b/docs/reference/api/containers/optioncontainer.rst @@ -23,9 +23,11 @@ A container that can display multiple labeled tabs of content. :align: center :width: 450px - .. group-tab:: Android |no| + .. group-tab:: Android - Not supported + .. figure:: /reference/images/optioncontainer-android.png + :align: center + :width: 450px .. group-tab:: iOS @@ -148,12 +150,20 @@ Notes the user to select the additional items. While the "More" menu is displayed, the current tab will return as ``None``. +* Android can only display 5 tabs. The API will allow you to add more than 5 tabs, and + will allow you to programmatically control tabs past the 5-item limit, but any tabs + past the limit will not be displayed or be selectable by user interaction. If the + OptionContainer has more than 5 tabs, and one of the visible tabs is removed, one of + the previously unselectable tabs will become visible and selectable. + * iOS allows the user to rearrange icons on an OptionContainer. When referring to tabs by index, user re-ordering is ignored; the logical order as configured in Toga itself is used to identify tabs. * Icons for iOS OptionContainer tabs should be 25x25px alpha masks. +* Icons for Android OptionContainer tabs should be 24x24px alpha masks. + Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index aec6e3dbdb..d0017f43f1 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -27,7 +27,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|,|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,|y|,|y|,|y|,,,, +OptionContainer,Layout Widget,:class:`~toga.OptionContainer`,A container that can display multiple labeled tabs of content,|y|,|y|,|y|,|y|,|y|,, Camera,Hardware,:class:`~toga.hardware.camera.Camera`,A sensor that can capture photos and/or video.,|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|,,|b| Font,Resource,:class:`~toga.Font`,A text font,|y|,|y|,|y|,|y|,|y|,, diff --git a/docs/reference/images/optioncontainer-android.png b/docs/reference/images/optioncontainer-android.png new file mode 100644 index 0000000000..01521c4033 Binary files /dev/null and b/docs/reference/images/optioncontainer-android.png differ diff --git a/dummy/src/toga_dummy/widgets/optioncontainer.py b/dummy/src/toga_dummy/widgets/optioncontainer.py index d3057af29f..7e81c48d93 100644 --- a/dummy/src/toga_dummy/widgets/optioncontainer.py +++ b/dummy/src/toga_dummy/widgets/optioncontainer.py @@ -16,16 +16,16 @@ def create(self): self._action("create OptionContainer") self._items = [] - def add_content(self, index, text, widget, icon): - self._action("add content", index=index, text=text, widget=widget, icon=icon) + def add_option(self, index, text, widget, icon): + self._action("add option", index=index, text=text, widget=widget, icon=icon) self._items.insert(index, Option(text, widget, True, icon)) # 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): - self._action("remove content", index=index) + def remove_option(self, index): + self._action("remove option", index=index) del self._items[index] def set_option_enabled(self, index, enabled): diff --git a/examples/screenshot/screenshot/resources/landmark-android.png b/examples/screenshot/screenshot/resources/landmark-android.png new file mode 100644 index 0000000000..de24e9f472 Binary files /dev/null and b/examples/screenshot/screenshot/resources/landmark-android.png differ diff --git a/examples/screenshot/screenshot/resources/smile-android.png b/examples/screenshot/screenshot/resources/smile-android.png new file mode 100644 index 0000000000..cd6cb43fd4 Binary files /dev/null and b/examples/screenshot/screenshot/resources/smile-android.png differ diff --git a/gtk/src/toga_gtk/widgets/optioncontainer.py b/gtk/src/toga_gtk/widgets/optioncontainer.py index b71cd47e53..5333758910 100644 --- a/gtk/src/toga_gtk/widgets/optioncontainer.py +++ b/gtk/src/toga_gtk/widgets/optioncontainer.py @@ -14,7 +14,7 @@ def create(self): def gtk_on_switch_page(self, widget, page, page_num): self.interface.on_select() - def add_content(self, index, text, widget, icon): + def add_option(self, index, text, widget, icon): sub_container = TogaContainer() sub_container.content = widget @@ -24,7 +24,7 @@ def add_content(self, index, text, widget, icon): # tell the notebook to show all content. self.native.show_all() - def remove_content(self, index): + def remove_option(self, index): self.native.remove_page(index) self.sub_containers[index].content = None del self.sub_containers[index] diff --git a/gtk/tests_backend/widgets/optioncontainer.py b/gtk/tests_backend/widgets/optioncontainer.py index 890046585a..9cea43fb33 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 + max_tabs = None disabled_tab_selectable = False def repaint_needed(self): diff --git a/iOS/src/toga_iOS/widgets/optioncontainer.py b/iOS/src/toga_iOS/widgets/optioncontainer.py index b666150fa0..783c501225 100644 --- a/iOS/src/toga_iOS/widgets/optioncontainer.py +++ b/iOS/src/toga_iOS/widgets/optioncontainer.py @@ -79,7 +79,7 @@ def content_refreshed(self, container): container.min_width = container.content.interface.layout.min_width container.min_height = container.content.interface.layout.min_height - def add_content(self, index, text, widget, icon=None): + def add_option(self, index, text, widget, icon=None): # Create the container for the widget sub_container = ControlledContainer(on_refresh=self.content_refreshed) sub_container.content = widget @@ -102,7 +102,7 @@ def configure_tab_item(self, container, text, icon): tag=0, ) - def remove_content(self, index): + def remove_option(self, index): sub_container = self.sub_containers[index] sub_container.content = None del self.sub_containers[index] diff --git a/iOS/tests_backend/widgets/optioncontainer.py b/iOS/tests_backend/widgets/optioncontainer.py index d19008ee5e..d5cdbe8a2c 100644 --- a/iOS/tests_backend/widgets/optioncontainer.py +++ b/iOS/tests_backend/widgets/optioncontainer.py @@ -7,6 +7,7 @@ class OptionContainerProbe(SimpleProbe): native_attr = "native_controller" native_class = UITabBarController disabled_tab_selectable = False + max_tabs = None more_option_is_stateful = True @property diff --git a/testbed/pyproject.toml b/testbed/pyproject.toml index 44fac0c0eb..734ecad203 100644 --- a/testbed/pyproject.toml +++ b/testbed/pyproject.toml @@ -93,6 +93,15 @@ requires = [ test_requires = [ "fonttools==4.42.1", ] + +base_theme = "Theme.MaterialComponents.Light.DarkActionBar" + +build_gradle_dependencies = [ + "androidx.appcompat:appcompat:1.6.1", + "com.google.android.material:material:1.11.0", + "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0", +] + build_gradle_extra_content = """\ android.defaultConfig.python { // Coverage requires access to individual .py files. diff --git a/testbed/src/testbed/resources/new-tab-android.png b/testbed/src/testbed/resources/new-tab-android.png new file mode 100644 index 0000000000..de24e9f472 Binary files /dev/null and b/testbed/src/testbed/resources/new-tab-android.png differ diff --git a/testbed/src/testbed/resources/tab-icon-1-android.png b/testbed/src/testbed/resources/tab-icon-1-android.png new file mode 100644 index 0000000000..ce0b04b95b Binary files /dev/null and b/testbed/src/testbed/resources/tab-icon-1-android.png differ diff --git a/testbed/src/testbed/resources/tab-icon-2-android.png b/testbed/src/testbed/resources/tab-icon-2-android.png new file mode 100644 index 0000000000..cd6cb43fd4 Binary files /dev/null and b/testbed/src/testbed/resources/tab-icon-2-android.png differ diff --git a/testbed/tests/widgets/test_optioncontainer.py b/testbed/tests/widgets/test_optioncontainer.py index 24a98f99da..f3d50cb7c5 100644 --- a/testbed/tests/widgets/test_optioncontainer.py +++ b/testbed/tests/widgets/test_optioncontainer.py @@ -6,7 +6,6 @@ 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, @@ -61,7 +60,6 @@ async def on_select_handler(): @pytest.fixture async def widget(content1, content2, content3, on_select_handler): - skip_on_platforms("android") return toga.OptionContainer( content=[ ("Tab 1", content1, "resources/tab-icon-1"), @@ -137,114 +135,223 @@ async def test_select_tab_overflow(widget, probe, on_select_handler): ] extra_probes = [get_probe(w) for w in extra_widgets] - # Add the extra widgets - for i, extra in enumerate(extra_widgets, start=4): - widget.content.append(f"Tab {i}", extra) + # Some platforms (Android) are limited in the number of tabs they can display. + # Other platforms have no tab limit. + if probe.max_tabs is None: + # No tab limit. Add all the extra widgets + for i, extra in enumerate(extra_widgets, start=4): + widget.content.append(f"Tab {i}", extra) - await probe.redraw("Tab 1 should be selected initially") - assert widget.current_tab.index == 0 + await probe.redraw("Tab 1 should be selected initially") + assert widget.current_tab.index == 0 - # Ensure mock call count is clean - on_select_handler.reset_mock() + # Ensure mock call count is clean + on_select_handler.reset_mock() - # Some platforms have a "more" option for tabs beyond a display limit. If - # `select_more()` doesn't exist, that feature doesn't exist on the platform. - try: - probe.select_more() - await probe.redraw("More option should be displayed") - # When the "more" menu is visible, the current tab is None. - assert widget.current_tab.index is None - except AttributeError: - pass - - # on_select has been not been invoked - on_select_handler.assert_not_called() + # Some platforms (iOS) have a "more" option for tabs beyond a display limit. If + # `select_more()` doesn't exist, that feature doesn't exist on the platform. + try: + probe.select_more() + await probe.redraw("More option should be displayed") + # When the "more" menu is visible, the current tab is None. + assert widget.current_tab.index is None + except AttributeError: + pass - # Select the second last tab in the GUI - probe.select_tab(6) - await probe.redraw("Tab 7 should be selected") + # on_select has been not been invoked + on_select_handler.assert_not_called() - assert widget.current_tab.index == 6 - assert extra_probes[3].width > probe.width * 0.8 - assert extra_probes[3].height > probe.height * 0.8 + # Select the second last tab in the GUI + probe.select_tab(6) + await probe.redraw("Tab 7 should be selected") - # on_select has been invoked - on_select_handler.assert_called_once_with(widget) - on_select_handler.reset_mock() + assert widget.current_tab.index == 6 + assert extra_probes[3].width > probe.width * 0.8 + assert extra_probes[3].height > probe.height * 0.8 - # Select the last tab programmatically while already on a "more" option - widget.current_tab = "Tab 8" - await probe.redraw("Tab 8 should be selected") + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() - assert widget.current_tab.index == 7 - assert extra_probes[4].width > probe.width * 0.8 - assert extra_probes[4].height > probe.height * 0.8 - # on_select has been invoked - on_select_handler.assert_called_once_with(widget) - on_select_handler.reset_mock() + # Select the last tab programmatically while already on a "more" option + widget.current_tab = "Tab 8" + await probe.redraw("Tab 8 should be selected") - # Select the first tab in the GUI - probe.select_tab(0) - await probe.redraw("Tab 0 should be selected") - assert widget.current_tab.index == 0 - # on_select has been invoked - on_select_handler.assert_called_once_with(widget) - on_select_handler.reset_mock() + assert widget.current_tab.index == 7 + assert extra_probes[4].width > probe.width * 0.8 + assert extra_probes[4].height > probe.height * 0.8 + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() - # Select the "more" option again. If the more option is stateful, - # this will result is displaying the last "more" option selected - try: - probe.select_more() - if probe.more_option_is_stateful: - await probe.redraw("Previous more option should be displayed") - assert widget.current_tab.index == 7 - # more is stateful, so there's a been a select event for the - # previously selected "more" option. - on_select_handler.assert_called_once_with(widget) - on_select_handler.reset_mock() - - probe.reset_more() - await probe.redraw("More option should be reset") - else: - await probe.redraw("More option should be displayed") + # Select the first tab in the GUI + probe.select_tab(0) + await probe.redraw("Tab 0 should be selected") + assert widget.current_tab.index == 0 + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() - assert widget.current_tab.index is None - except AttributeError: - pass + # Select the "more" option again. If the more option is stateful, + # this will result is displaying the last "more" option selected + try: + probe.select_more() + if probe.more_option_is_stateful: + await probe.redraw("Previous more option should be displayed") + assert widget.current_tab.index == 7 + # more is stateful, so there's a been a select event for the + # previously selected "more" option. + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + probe.reset_more() + await probe.redraw("More option should be reset") + else: + await probe.redraw("More option should be displayed") + + assert widget.current_tab.index is None + except AttributeError: + pass - on_select_handler.assert_not_called() + on_select_handler.assert_not_called() - # Select the second last tab in the GUI - probe.select_tab(6) - await probe.redraw("Tab 7 should be selected") + # Select the second last tab in the GUI + probe.select_tab(6) + await probe.redraw("Tab 7 should be selected") - assert widget.current_tab.index == 6 - assert extra_probes[3].width > probe.width * 0.8 - assert extra_probes[3].height > probe.height * 0.8 + assert widget.current_tab.index == 6 + assert extra_probes[3].width > probe.width * 0.8 + assert extra_probes[3].height > probe.height * 0.8 - # on_select has been invoked - on_select_handler.assert_called_once_with(widget) - on_select_handler.reset_mock() + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() - # Select the first tab in the GUI - probe.select_tab(0) - await probe.redraw("Tab 0 should be selected") - assert widget.current_tab.index == 0 - # on_select has been invoked - on_select_handler.assert_called_once_with(widget) - on_select_handler.reset_mock() + # Select the first tab in the GUI + probe.select_tab(0) + await probe.redraw("Tab 0 should be selected") + assert widget.current_tab.index == 0 + # on_select has been invoked + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() - # Select the last tab programmatically while on a non-more option - widget.current_tab = "Tab 8" - await probe.redraw("Tab 8 should be selected") + # Select the last tab programmatically while on a non-more option + widget.current_tab = "Tab 8" + await probe.redraw("Tab 8 should be selected") - assert widget.current_tab.index == 7 - assert extra_probes[4].width > probe.width * 0.8 - assert extra_probes[4].height > probe.height * 0.8 - # on_select has been invoked. There may be a refresh event - # associated with the display of the the stateful more option. - on_select_handler.assert_called_with(widget) - on_select_handler.reset_mock() + assert widget.current_tab.index == 7 + assert extra_probes[4].width > probe.width * 0.8 + assert extra_probes[4].height > probe.height * 0.8 + # on_select has been invoked. There may be a refresh event + # associated with the display of the the stateful more option. + on_select_handler.assert_called_with(widget) + on_select_handler.reset_mock() + else: + # Platform has a tab limit. Add as many tabs as the tab limit allows + for i in range(4, probe.max_tabs + 1): + extra = extra_widgets.pop(0) + extra_probes.pop(0) + widget.content.append(f"Tab {i}", extra) + + await probe.redraw("OptionContainer is at capacity") + + # Ensure mock call count is clean + on_select_handler.reset_mock() + + # Append two more widgets. This raises a warning; the new content will + # be stored, but not displayed + with pytest.warns(match=r"Additional item will be ignored"): + extra = extra_widgets.pop(0) + extra_probes.pop(0) + widget.content.append("Tab A", extra) + + with pytest.warns(match=r"Additional item will be ignored"): + extra = extra_widgets.pop(0) + extra_probes.pop(0) + widget.content.append("Tab B", extra) + + await probe.redraw("Appended items were ignored") + + # Excess tab details can still be read and written + widget.content[probe.max_tabs].text = "Extra Tab" + widget.content[probe.max_tabs].icon = "resources/new-tab" + widget.content[probe.max_tabs].enabled = False + + assert widget.content[probe.max_tabs].text == "Extra Tab" + probe.assert_tab_icon(probe.max_tabs, "new-tab") + assert not widget.content[probe.max_tabs].enabled + + assert widget.content[probe.max_tabs + 1].text == "Tab B" + probe.assert_tab_icon(probe.max_tabs + 1, None) + assert widget.content[probe.max_tabs + 1].enabled + + # Programmatically selecting a non-visible tab raises a warning, doesn't change + # the tab, and doesn't generate a selection event. + with pytest.warns(match=r"Tab is outside selectable range"): + widget.current_tab = probe.max_tabs + 1 + + await probe.redraw("Item selection was ignored") + on_select_handler.assert_not_called() + + # Insert a tab at the start. This will bump the last tab into the void + with pytest.warns(match=r"Excess items will be ignored"): + extra = extra_widgets.pop(0) + extra_probes.pop(0) + widget.content.insert(2, "Tab C", extra) + + await probe.redraw("Inserted item bumped the last item") + + # Assert the properties of the last visible item + assert widget.content[probe.max_tabs - 1].text == f"Tab {probe.max_tabs - 1}" + probe.assert_tab_icon(probe.max_tabs - 1, None) + assert widget.content[probe.max_tabs - 1].enabled + + # As the item is visible, also verify the actual widget properties + probe.assert_tab_content( + probe.max_tabs - 1, + f"Tab {probe.max_tabs - 1}", + enabled=True, + ) + + # Assert the properties of the first invisible item + assert widget.content[probe.max_tabs].text == f"Tab {probe.max_tabs}" + probe.assert_tab_icon(probe.max_tabs, None) + assert widget.content[probe.max_tabs].enabled + + # Remove a visible tab. This will bring the tab that was bumped by + # the previous insertion back into view. + widget.content.remove(1) + + await probe.redraw("Deleting an item restores previously bumped item") + + assert widget.content[probe.max_tabs - 1].text == f"Tab {probe.max_tabs}" + probe.assert_tab_icon(probe.max_tabs - 1, None) + assert widget.content[probe.max_tabs - 1].enabled + + # As the item is visible, also verify the actual widget properties + probe.assert_tab_content( + probe.max_tabs - 1, + f"Tab {probe.max_tabs}", + enabled=True, + ) + + # Remove another visible tab. This will make the first "extra" tab + # come into view for the first time. It has a custom icon, and + # was disabled while it wasn't visible. + widget.content.remove(1) + + await probe.redraw("Deleting an item creates a previously excess item") + + assert widget.content[probe.max_tabs - 1].text == "Extra Tab" + probe.assert_tab_icon(probe.max_tabs - 1, "new-tab") + assert not widget.content[probe.max_tabs - 1].enabled + + # As the item is visible, also verify the actual widget properties + probe.assert_tab_content( + probe.max_tabs - 1, + "Extra Tab", + enabled=False, + ) async def test_enable_tab(widget, probe, on_select_handler): diff --git a/winforms/src/toga_winforms/widgets/optioncontainer.py b/winforms/src/toga_winforms/widgets/optioncontainer.py index 2b3aa5c1b8..2486b0f93b 100644 --- a/winforms/src/toga_winforms/widgets/optioncontainer.py +++ b/winforms/src/toga_winforms/widgets/optioncontainer.py @@ -13,7 +13,7 @@ def create(self): self.native.Selected += WeakrefCallable(self.winforms_selected) self.panels = [] - def add_content(self, index, text, widget, icon): + def add_option(self, index, text, widget, icon): page = TabPage(text) self.native.TabPages.Insert(index, page) @@ -29,7 +29,7 @@ def add_content(self, index, text, widget, icon): page.ClientSizeChanged += WeakrefCallable(self.winforms_client_size_changed) - def remove_content(self, index): + def remove_option(self, index): panel = self.panels.pop(index) panel.clear_content() diff --git a/winforms/tests_backend/widgets/optioncontainer.py b/winforms/tests_backend/widgets/optioncontainer.py index 66c16e0995..bcd724b7d6 100644 --- a/winforms/tests_backend/widgets/optioncontainer.py +++ b/winforms/tests_backend/widgets/optioncontainer.py @@ -5,6 +5,7 @@ class OptionContainerProbe(SimpleProbe): native_class = TabControl + max_tabs = None disabled_tab_selectable = True def select_tab(self, index):