diff --git a/android/src/toga_android/widgets/detailedlist.py b/android/src/toga_android/widgets/detailedlist.py index 53c4599dcf..db30688376 100644 --- a/android/src/toga_android/widgets/detailedlist.py +++ b/android/src/toga_android/widgets/detailedlist.py @@ -140,14 +140,10 @@ def _make_row(self, container, i, row): row_view.setOnLongClickListener(DetailedListOnLongClickListener(self, i)) row_height = self.scale_in(64) - title, subtitle, icon = ( - getattr(row, attr, None) for attr in self.interface.accessors - ) - # Add user-provided icon to layout. icon_image_view = ImageView(self._native_activity) - if icon is not None: - icon_image_view.setImageBitmap(icon._impl.native) + if self.interface._icon(row) is not None: + icon_image_view.setImageBitmap(self.interface._icon(row)._impl.native) icon_layout_params = RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT, @@ -172,18 +168,14 @@ def _make_row(self, container, i, row): text_container.setOrientation(LinearLayout.VERTICAL) text_container.setWeightSum(2.0) - # Create top & bottom text; add them to layout. - def get_string(value): - if value is None: - value = self.interface.missing_value - return str(value) - + # _title() and _subtitle() handle None, convert to str, and return only the + # first line so that newline characters are not rendered by the TextView. top_text = TextView(self._native_activity) - top_text.setText(get_string(title)) + top_text.setText(self.interface._title(row)) top_text.setTextSize(20.0) top_text.setTextColor(self.get_theme_color(R.attr.textColorPrimary)) bottom_text = TextView(self._native_activity) - bottom_text.setText(get_string(subtitle)) + bottom_text.setText(self.interface._subtitle(row)) bottom_text.setTextSize(16.0) bottom_text.setTextColor(self.get_theme_color(R.attr.textColorSecondary)) top_text_params = LinearLayout.LayoutParams( diff --git a/changes/2343.bugfix.md b/changes/2343.bugfix.md new file mode 100644 index 0000000000..e23689ce9c --- /dev/null +++ b/changes/2343.bugfix.md @@ -0,0 +1 @@ +The `DetailedList` widget now displays only the first line of title and subtitle text. diff --git a/cocoa/src/toga_cocoa/widgets/detailedlist.py b/cocoa/src/toga_cocoa/widgets/detailedlist.py index 8e231df326..fcb131f675 100644 --- a/cocoa/src/toga_cocoa/widgets/detailedlist.py +++ b/cocoa/src/toga_cocoa/widgets/detailedlist.py @@ -86,28 +86,11 @@ def tableView_objectValueForTableColumn_row_(self, table, column, row: int): data = TogaData.alloc().init() value._impl = data - try: - title = getattr(value, self.interface.accessors[0]) - if title is not None: - title = str(title) - else: - title = self.interface.missing_value - except AttributeError: - title = self.interface.missing_value + title = self.interface._title(value) + subtitle = self.interface._subtitle(value) - try: - subtitle = getattr(value, self.interface.accessors[1]) - if subtitle is not None: - subtitle = str(subtitle) - else: - subtitle = self.interface.missing_value - except AttributeError: - subtitle = self.interface.missing_value - - try: - icon = getattr(value, self.interface.accessors[2])._impl.native - except AttributeError: - icon = None + icon_obj = self.interface._icon(value) + icon = None if icon_obj is None else icon_obj._impl.native data.attrs = { "title": title, diff --git a/core/src/toga/widgets/detailedlist.py b/core/src/toga/widgets/detailedlist.py index 21d646d30c..47ea68852f 100644 --- a/core/src/toga/widgets/detailedlist.py +++ b/core/src/toga/widgets/detailedlist.py @@ -186,6 +186,44 @@ def missing_value(self) -> str: """ return self._missing_value + def _as_text(self, value: object) -> str: + """Convert a raw value to a single-line display string (internal helper). + + If the value is None, the widget's missing_value is used instead. + Only the first line of the string representation is returned, so + that titles and subtitles containing newline characters are not + rendered as multiple lines. + """ + if value is None: + value = self.missing_value + text = str(value) + lines = text.splitlines() + return lines[0] if lines else "" + + def _title(self, row: object) -> str: + """Retrieve the title for a row as a single-line display string. + + Reads the title attribute (first accessor), substitutes missing_value + for None, and truncates at the first newline character. + """ + return self._as_text(getattr(row, self._accessors[0], None)) + + def _subtitle(self, row: object) -> str: + """Retrieve the subtitle for a row as a single-line display string. + + Reads the subtitle attribute (second accessor), substitutes missing_value + for None, and truncates at the first newline character. + """ + return self._as_text(getattr(row, self._accessors[1], None)) + + def _icon(self, row: object) -> object: + """Retrieve the icon for a row. + + Returns the icon object (third accessor), or None if the attribute + is absent or not set. + """ + return getattr(row, self._accessors[2], None) + @property def selection(self) -> Row | None: """The current selection of the table. diff --git a/core/tests/widgets/test_detailedlist.py b/core/tests/widgets/test_detailedlist.py index 95f65e3969..e405d7e779 100644 --- a/core/tests/widgets/test_detailedlist.py +++ b/core/tests/widgets/test_detailedlist.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import Mock import pytest @@ -321,3 +322,66 @@ def test_scroll_to_bottom(detailedlist): detailedlist.scroll_to_bottom() assert_action_performed_with(detailedlist, "scroll to row", row=2) + + +@pytest.mark.parametrize( + "value, expected", + [ + # Plain text is returned unchanged + ("hello", "hello"), + # Non-string values are converted to string first + (42, "42"), + # None is replaced by the widget's missing_value (default is "") + (None, ""), + # Only the first line is returned when \n is present + ("hello\nworld", "hello"), + # Windows-style line endings (\r\n) are also handled + ("hello\r\nworld", "hello"), + # A leading newline means the first line is empty + ("\nhello", ""), + # A completely empty string stays empty + ("", ""), + ], +) +def test_as_text(detailedlist, value, expected): + """_as_text() converts a value to a single-line display string. + + Only the first line of the string representation is returned. + None values are replaced by the widget's missing_value. + """ + assert detailedlist._as_text(value) == expected + + +def test_title(detailedlist): + """_title() reads the title accessor from a row and returns a single-line string.""" + # Multiline value is truncated at the first newline + row = SimpleNamespace(key="hello\nworld", value="subtitle", icon=None) + assert detailedlist._title(row) == "hello" + + +def test_title_missing_attribute(detailedlist): + """_title() returns missing_value when the row has no title attribute.""" + row = SimpleNamespace(value="subtitle", icon=None) # no 'key' attribute + assert detailedlist._title(row) == "" # default missing_value is "" + + +def test_subtitle(detailedlist): + """_subtitle() reads the subtitle attribute from a row as a single-line string.""" + row = SimpleNamespace(key="title", value="hello\nworld", icon=None) + assert detailedlist._subtitle(row) == "hello" + + +def test_subtitle_missing_attribute(detailedlist): + """_subtitle() returns missing_value when the row has no subtitle attribute.""" + row = SimpleNamespace(key="title", icon=None) # no 'value' attribute + assert detailedlist._subtitle(row) == "" # default missing_value is "" + + +def test_icon(detailedlist): + """_icon() returns the icon attribute of a row, or None if absent.""" + mock_icon = object() + row = SimpleNamespace(key="", value="", icon=mock_icon) + assert detailedlist._icon(row) is mock_icon + assert detailedlist._icon(SimpleNamespace(key="", value="", icon=None)) is None + # Missing attribute returns None via getattr default + assert detailedlist._icon(SimpleNamespace(key="", value="")) is None diff --git a/gtk/src/toga_gtk/widgets/detailedlist.py b/gtk/src/toga_gtk/widgets/detailedlist.py index 626c4eb5a7..7fb11435a1 100644 --- a/gtk/src/toga_gtk/widgets/detailedlist.py +++ b/gtk/src/toga_gtk/widgets/detailedlist.py @@ -56,23 +56,8 @@ def update(self, dl, row): and accessors from the detailedList""" # Set the title and subtitle as a block of HTML text. - try: - title = getattr(self.row, dl.accessors[0]) - if title is not None: - title = str(title) - else: - title = dl.missing_value - except AttributeError: - title = dl.missing_value - - try: - subtitle = getattr(self.row, dl.accessors[1]) - if subtitle is not None: - subtitle = str(subtitle) - else: - subtitle = dl.missing_value - except AttributeError: - subtitle = dl.missing_value + title = dl._title(self.row) + subtitle = dl._subtitle(self.row) markup = "".join( [ @@ -89,10 +74,8 @@ def update(self, dl, row): if self.icon: self.content.remove(self.icon) - try: - pixbuf = getattr(self.row, dl.accessors[2])._impl.native(32) - except AttributeError: - pixbuf = None + icon = dl._icon(self.row) + pixbuf = icon._impl.native(32) if icon is not None else None if pixbuf is not None: self.icon = Gtk.Image.new_from_pixbuf(pixbuf) diff --git a/iOS/src/toga_iOS/widgets/detailedlist.py b/iOS/src/toga_iOS/widgets/detailedlist.py index b36983b3fa..00e1e572e3 100644 --- a/iOS/src/toga_iOS/widgets/detailedlist.py +++ b/iOS/src/toga_iOS/widgets/detailedlist.py @@ -47,30 +47,11 @@ def tableView_cellForRowAtIndexPath_(self, tableView, indexPath): value = self.interface.data[indexPath.item] - try: - label = getattr(value, self.interface.accessors[0]) - if label is None: - cell.textLabel.text = self.interface.missing_value - else: - cell.textLabel.text = str(label) - except AttributeError: - cell.textLabel.text = self.interface.missing_value - - try: - label = getattr(value, self.interface.accessors[1]) - if label is None: - cell.detailTextLabel.text = self.interface.missing_value - else: - cell.detailTextLabel.text = str(label) - except AttributeError: - cell.detailTextLabel.text = self.interface.missing_value - - try: - cell.imageView.image = getattr( - value, self.interface.accessors[2] - )._impl.native - except AttributeError: - cell.imageView.image = None + cell.textLabel.text = self.interface._title(value) + cell.detailTextLabel.text = self.interface._subtitle(value) + + icon = self.interface._icon(value) + cell.imageView.image = None if icon is None else icon._impl.native return cell diff --git a/qt/src/toga_qt/widgets/detailedlist.py b/qt/src/toga_qt/widgets/detailedlist.py index 19c83790a2..a0174d025a 100644 --- a/qt/src/toga_qt/widgets/detailedlist.py +++ b/qt/src/toga_qt/widgets/detailedlist.py @@ -459,8 +459,15 @@ def create(self): self.native_model = ListSourceModel( self.interface.data, { - Qt.ItemDataRole.UserRole: user_formatter, - Qt.ItemDataRole.DecorationRole: icon_formatter, + Qt.ItemDataRole.UserRole: lambda row, _: ( + self.interface._title(row), + self.interface._subtitle(row), + ), + Qt.ItemDataRole.DecorationRole: lambda row, _: ( + icon._impl.native + if (icon := self.interface._icon(row)) is not None + else QIcon() + ), }, parent=self.native, ) diff --git a/winforms/src/toga_winforms/widgets/detailedlist.py b/winforms/src/toga_winforms/widgets/detailedlist.py index 7e170b9971..b518d44444 100644 --- a/winforms/src/toga_winforms/widgets/detailedlist.py +++ b/winforms/src/toga_winforms/widgets/detailedlist.py @@ -746,14 +746,10 @@ def _image_index(self, icon): def _new_item(self, index) -> tuple[str, str, int]: row = self._data[index] - - title, subtitle, icon = ( - getattr(row, attr, None) for attr in self.interface.accessors - ) - + icon = self.interface._icon(row) return ( - str(self._missing_value) if title is None else str(title), - str(self._missing_value) if subtitle is None else str(subtitle), + self.interface._title(row), + self.interface._subtitle(row), -1 if icon is None else self._image_index(icon._impl), )