diff --git a/changes/2025.feature.4.rst b/changes/2025.feature.4.rst new file mode 100644 index 0000000000..c20370c7b3 --- /dev/null +++ b/changes/2025.feature.4.rst @@ -0,0 +1 @@ +DetailedList can now respond to "primary" and "secondary" user actions. These may be implemented as left and right swipe respectively, or using any other platform-appropriate mechanism. diff --git a/changes/2025.removal.2.rst b/changes/2025.removal.2.rst new file mode 100644 index 0000000000..7b3dc14033 --- /dev/null +++ b/changes/2025.removal.2.rst @@ -0,0 +1 @@ +The ``on_select`` handler for DetailedList no longer receives the selected row as an argument. diff --git a/changes/2025.removal.3.rst b/changes/2025.removal.3.rst new file mode 100644 index 0000000000..14e44ed1af --- /dev/null +++ b/changes/2025.removal.3.rst @@ -0,0 +1 @@ +The handling of row deletion in DetailedList widgets has been significantly altered. The ``on_delete`` event handler has been renamed ``on_primary_action``, and is now *only* a notification that a "swipe left" event (or platform equivalent) has been confirmed. This was previously inconsistent across platforms. Some platforms would update the data source to remove the row; some treated ``on_delete`` as a notification event and expected the application to handle the deletion. It is now the application's responsibility to perform the data deletion. diff --git a/core/src/toga/widgets/detailedlist.py b/core/src/toga/widgets/detailedlist.py index 55327fbbaa..69642c1aff 100644 --- a/core/src/toga/widgets/detailedlist.py +++ b/core/src/toga/widgets/detailedlist.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import Any from toga.handlers import wrapped_handler @@ -16,9 +17,13 @@ def __init__( data: Any = None, accessors: tuple[str, str, str] = ("title", "subtitle", "icon"), missing_value: str = "", - on_delete: callable = None, + primary_action: str | None = "Delete", + on_primary_action: callable = None, + secondary_action: str | None = "Action", + on_secondary_action: callable = None, on_refresh: callable = None, on_select: callable = None, + on_delete: callable = None, # DEPRECATED ): """Create a new DetailedList widget. @@ -35,21 +40,44 @@ def __init__( :param missing_value: The data to use subtitle to use when the data source doesn't provide a title for a data item. :param on_select: Initial :any:`on_select` handler. + :param primary_action: The name for the primary action. + :param on_primary_action: Initial :any:`on_primary_action` handler. + :param secondary_action: The name for the primary action. + :param on_secondary_action: Initial :any:`on_secondary_action` handler. :param on_refresh: Initial :any:`on_refresh` handler. - :param on_delete: Initial :any:`on_delete` handler. + :param on_delete: **DEPRECATED**; use :attr:`on_activate`. """ super().__init__(id=id, style=style) + + ###################################################################### + # 2023-06: Backwards compatibility + ###################################################################### + if on_delete: + if on_primary_action: + raise ValueError("Cannot specify both on_delete and on_primary_action") + else: + warnings.warn( + "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", + DeprecationWarning, + ) + on_primary_action = on_delete + ###################################################################### + # End backwards compatibility. + ###################################################################### + + # Prime the attributes and handlers that need to exist when the widget is created. self._accessors = accessors + self._primary_action = primary_action + self._secondary_action = secondary_action self._missing_value = missing_value self._data = None - self.on_delete = None - self.on_refresh = None self.on_select = None self._impl = self.factory.DetailedList(interface=self) self.data = data - self.on_delete = on_delete + self.on_primary_action = on_primary_action + self.on_secondary_action = on_secondary_action self.on_refresh = on_refresh self.on_select = on_select @@ -66,7 +94,7 @@ def enabled(self, value): pass def focus(self): - "No-op; DetailList cannot accept input focus" + "No-op; DetailedList cannot accept input focus" pass @property @@ -153,24 +181,59 @@ def selection(self) -> Row | None: return None @property - def on_delete(self) -> callable: - """The handler to invoke when the user performs a deletion action on a row of the - DetailedList.""" - return self._on_delete + def on_primary_action(self) -> callable: + """The handler to invoke when the user performs the primary action on a row of + the DetailedList. - @on_delete.setter - def on_delete(self, handler: callable): - self._on_delete = wrapped_handler(self, handler) + The primary action is "swipe left" on UIs that support swipe interactions; + platforms that don't use swipe interactions may manifest this action in other + ways (e.g, a context menu). + + If no ``on_primary_action`` handler is provided, the primary action will be + disabled in the UI. + """ + return self._on_primary_action + + @on_primary_action.setter + def on_primary_action(self, handler: callable): + self._on_primary_action = wrapped_handler(self, handler) + self._impl.set_primary_action_enabled(handler is not None) + + @property + def on_secondary_action(self) -> callable: + """The handler to invoke when the user performs the secondary action on a row of + the DetailedList. + + The secondary action is "swipe right" on UIs that support swipe interactions; + platforms that don't use swipe interactions may manifest this action in other + ways (e.g, a context menu). + + If no ``on_secondary_action`` handler is provided, the secondary action will be + disabled in the UI. + """ + return self._on_secondary_action + + @on_secondary_action.setter + def on_secondary_action(self, handler: callable): + self._on_secondary_action = wrapped_handler(self, handler) + self._impl.set_secondary_action_enabled(handler is not None) @property def on_refresh(self) -> callable: - """The callback function to invoke when the user performs a refresh action on the - DetailedList.""" + """The callback function to invoke when the user performs a refresh action + (usually "pull down to refresh") on the DetailedList. + + If no ``on_refresh`` handler is provided, the refresh UI action will be + disabled. + """ return self._on_refresh @on_refresh.setter def on_refresh(self, handler: callable): - self._on_refresh = wrapped_handler(self, handler) + self._on_refresh = wrapped_handler( + self, handler, cleanup=self._impl.after_on_refresh + ) + self._impl.set_refresh_enabled(handler is not None) @property def on_select(self) -> callable: @@ -180,3 +243,24 @@ def on_select(self) -> callable: @on_select.setter def on_select(self, handler: callable): self._on_select = wrapped_handler(self, handler) + + ###################################################################### + # 2023-06: Backwards compatibility + ###################################################################### + + @property + def on_delete(self): + """**DEPRECATED**: Use ``on_primary_action``""" + warnings.warn( + "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", + DeprecationWarning, + ) + return self.on_primary_action + + @on_delete.setter + def on_delete(self, handler): + warnings.warn( + "DetailedList.on_delete has been renamed DetailedList.on_primary_action.", + DeprecationWarning, + ) + self.on_primary_action = handler diff --git a/core/tests/widgets/test_detailedlist.py b/core/tests/widgets/test_detailedlist.py index e52ed93d97..eb051a632c 100644 --- a/core/tests/widgets/test_detailedlist.py +++ b/core/tests/widgets/test_detailedlist.py @@ -18,11 +18,16 @@ def on_select_handler(): @pytest.fixture def on_refresh_handler(): + return Mock(return_value=None) + + +@pytest.fixture +def on_primary_action_handler(): return Mock() @pytest.fixture -def on_delete_handler(): +def on_secondary_action_handler(): return Mock() @@ -39,13 +44,20 @@ def source(): @pytest.fixture -def detailedlist(source, on_select_handler, on_refresh_handler, on_delete_handler): +def detailedlist( + source, + on_select_handler, + on_refresh_handler, + on_primary_action_handler, + on_secondary_action_handler, +): return toga.DetailedList( accessors=["key", "value", "icon"], data=source, on_select=on_select_handler, on_refresh=on_refresh_handler, - on_delete=on_delete_handler, + on_primary_action=on_primary_action_handler, + on_secondary_action=on_secondary_action_handler, ) @@ -60,14 +72,24 @@ def test_detailedlist_created(): assert detailedlist.missing_value == "" assert detailedlist.on_select._raw is None assert detailedlist.on_refresh._raw is None - assert detailedlist.on_delete._raw is None + assert detailedlist.on_primary_action._raw is None + assert detailedlist.on_secondary_action._raw is None + assert detailedlist._primary_action == "Delete" + assert detailedlist._secondary_action == "Action" + + assert_action_performed_with(detailedlist, "refresh enabled", enabled=False) + assert_action_performed_with(detailedlist, "primary action enabled", enabled=False) + assert_action_performed_with( + detailedlist, "secondary action enabled", enabled=False + ) def test_create_with_values( source, on_select_handler, on_refresh_handler, - on_delete_handler, + on_primary_action_handler, + on_secondary_action_handler, ): "A DetailedList can be created with initial values" detailedlist = toga.DetailedList( @@ -76,7 +98,10 @@ def test_create_with_values( missing_value="Boo!", on_select=on_select_handler, on_refresh=on_refresh_handler, - on_delete=on_delete_handler, + primary_action="Primary", + on_primary_action=on_primary_action_handler, + secondary_action="Secondary", + on_secondary_action=on_secondary_action_handler, ) assert detailedlist._impl.interface == detailedlist assert_action_performed(detailedlist, "create DetailedList") @@ -86,7 +111,14 @@ def test_create_with_values( assert detailedlist.missing_value == "Boo!" assert detailedlist.on_select._raw == on_select_handler assert detailedlist.on_refresh._raw == on_refresh_handler - assert detailedlist.on_delete._raw == on_delete_handler + assert detailedlist.on_primary_action._raw == on_primary_action_handler + assert detailedlist.on_secondary_action._raw == on_secondary_action_handler + assert detailedlist._primary_action == "Primary" + assert detailedlist._secondary_action == "Secondary" + + assert_action_performed_with(detailedlist, "refresh enabled", enabled=True) + assert_action_performed_with(detailedlist, "primary action enabled", enabled=True) + assert_action_performed_with(detailedlist, "secondary action enabled", enabled=True) def test_disable_no_op(detailedlist): @@ -214,6 +246,23 @@ def test_selection(detailedlist, on_select_handler): on_select_handler.assert_called_once_with(detailedlist) +def test_refresh(detailedlist, on_refresh_handler): + "Completion of a refresh event triggers the cleanup handler" + # Stimulate a refresh. + detailedlist._impl.stimulate_refresh() + + # refresh handler was invoked + on_refresh_handler.assert_called_once_with(detailedlist) + + # The post-refresh handler was invoked on the backend + assert_action_performed_with( + detailedlist, + "after on refresh", + widget=detailedlist, + result=None, + ) + + def test_scroll_to_top(detailedlist): "A DetailedList can be scrolled to the top" detailedlist.scroll_to_top() @@ -257,3 +306,43 @@ def test_scroll_to_bottom(detailedlist): detailedlist.scroll_to_bottom() assert_action_performed_with(detailedlist, "scroll to row", row=2) + + +###################################################################### +# 2023-07: Backwards compatibility +###################################################################### +def test_deprecated_names(on_primary_action_handler): + "Deprecated names still work" + + # Can't specify both on_delete and on_primary_action + with pytest.raises( + ValueError, + match=r"Cannot specify both on_delete and on_primary_action", + ): + toga.DetailedList(on_delete=Mock(), on_primary_action=Mock()) + + # on_delete is redirected at construction + with pytest.warns( + DeprecationWarning, + match="DetailedList.on_delete has been renamed DetailedList.on_primary_action", + ): + select = toga.DetailedList(on_delete=on_primary_action_handler) + + # on_delete accessor is redirected to on_primary_action + with pytest.warns( + DeprecationWarning, + match="DetailedList.on_delete has been renamed DetailedList.on_primary_action", + ): + assert select.on_delete._raw == on_primary_action_handler + + assert select.on_primary_action._raw == on_primary_action_handler + + # on_delete mutator is redirected to on_primary_action + new_handler = Mock() + with pytest.warns( + DeprecationWarning, + match="DetailedList.on_delete has been renamed DetailedList.on_primary_action", + ): + select.on_delete = new_handler + + assert select.on_primary_action._raw == new_handler diff --git a/docs/reference/api/widgets/detailedlist.rst b/docs/reference/api/widgets/detailedlist.rst index cb3b372c83..018c04b588 100644 --- a/docs/reference/api/widgets/detailedlist.rst +++ b/docs/reference/api/widgets/detailedlist.rst @@ -88,6 +88,27 @@ converted into a string. If the value provided by the accessor for icon is :any:`None`, or the accessor isn't defined, a no icon will be displayed, but space for the icon will remain the the layout. +Items in a DetailedList can respond to a primary and secondary action. On platforms that +support swipe interactions, the primary action will be associated with "swipe left"; the +secondary action will be associated with "swipe right". However, a platform may +implement the primary and secondary actions using a different UI interaction (e.g., a +right-click context menu). The primary and secondary actions will only be enabled in +the DetailedList UI if a handler has been provided. + +By default, the primary and secondary action will be labeled as "Delete" and "Action", +respectively. These names can be overridden by providing a ``primary_action`` and +``secondary_action`` argument when constructing the DetailedList. Although the primary +action is labeled "Delete" by default, the DetailedList will not perform any data +deletion as part of the UI interaction. It is the responsibility of the application to +implement any data deletion behavior as part of the ``on_primary_action`` handler. + +The DetailedList as a whole can also respond to a refresh UI action. This is usually +implemented as a "pull down to refresh" action, such as you might see on a social media +timeline. If a DetailedList widget provides an ``on_refresh`` handler, the DetailedList +will respond to the refresh UI action, and the ``on_refresh`` handler will be invoked. +If no ``on_refresh`` handler is provided, the DetailedList will behave as a static list, +and will *not* respond to the refresh UI action. + Reference --------- diff --git a/dummy/src/toga_dummy/widgets/detailedlist.py b/dummy/src/toga_dummy/widgets/detailedlist.py index 36a8ff9644..09e84fdbb1 100644 --- a/dummy/src/toga_dummy/widgets/detailedlist.py +++ b/dummy/src/toga_dummy/widgets/detailedlist.py @@ -26,6 +26,15 @@ def clear(self): def get_selection(self): return self._get_value("selection", None) + def set_refresh_enabled(self, enabled): + self._action("refresh enabled", enabled=enabled) + + def set_primary_action_enabled(self, enabled): + self._action("primary action enabled", enabled=enabled) + + def set_secondary_action_enabled(self, enabled): + self._action("secondary action enabled", enabled=enabled) + def after_on_refresh(self, widget, result): self._action("after on refresh", widget=widget, result=result) @@ -35,3 +44,6 @@ def scroll_to_row(self, row): def simulate_selection(self, row): self._set_value("selection", row) self.interface.on_select(None) + + def stimulate_refresh(self): + self.interface.on_refresh(None) diff --git a/examples/detailedlist/detailedlist/app.py b/examples/detailedlist/detailedlist/app.py index 83cbc6f28b..dbd0fd2b7e 100644 --- a/examples/detailedlist/detailedlist/app.py +++ b/examples/detailedlist/detailedlist/app.py @@ -9,7 +9,8 @@ class ExampleDetailedListApp(toga.App): # Detailed list callback functions - def on_select_handler(self, widget, row, **kwargs): + def on_select_handler(self, widget, **kwargs): + row = widget.selection self.label.text = ( f"Bee is {row.title} in {row.subtitle}" if row is not None @@ -26,6 +27,10 @@ async def on_refresh_handler(self, widget, **kwargs): def on_delete_handler(self, widget, row, **kwargs): self.label.text = f"Row {row.subtitle} is going to be deleted." + self.dl.data.remove(row) + + def on_visit_handler(self, widget, row, **kwargs): + self.label.text = "We're not a travel agent." # Button callback functions def insert_handler(self, widget, **kwargs): @@ -71,7 +76,9 @@ def startup(self): for translation in bee_translations ], on_select=self.on_select_handler, - on_delete=self.on_delete_handler, + on_primary_action=self.on_delete_handler, + secondary_action="Visit", + on_secondary_action=self.on_visit_handler, on_refresh=self.on_refresh_handler, style=Pack(flex=1), )