Skip to content

Commit

Permalink
Modify core API to use primary/secondary actions.
Browse files Browse the repository at this point in the history
  • Loading branch information
freakboy3742 committed Jul 5, 2023
1 parent da40e85 commit 0fa6042
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 25 deletions.
1 change: 1 addition & 0 deletions changes/2025.feature.4.rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changes/2025.removal.2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``on_select`` handler for DetailedList no longer receives the selected row as an argument.
1 change: 1 addition & 0 deletions changes/2025.removal.3.rst
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 100 additions & 16 deletions core/src/toga/widgets/detailedlist.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import warnings
from typing import Any

from toga.handlers import wrapped_handler
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
103 changes: 96 additions & 7 deletions core/tests/widgets/test_detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand All @@ -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,
)


Expand All @@ -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(
Expand All @@ -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")
Expand All @@ -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):
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
21 changes: 21 additions & 0 deletions docs/reference/api/widgets/detailedlist.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------

Expand Down
12 changes: 12 additions & 0 deletions dummy/src/toga_dummy/widgets/detailedlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Loading

0 comments on commit 0fa6042

Please sign in to comment.