Repository: beeware/toga

Issue:
Array size limited when using toga.Selection
### Describe the bug

When creating an array, and setting said array as the items in toga.Selection, size is limited to 27 selections (specifically on iOS) before error occurs.

### Steps to reproduce

1. Create array larger than 27 items. 
2. toga.Selection(items = array)
3. Run Program 
4. Error: `2024-01-03 16:53:10.906 python[65170:960463] *** Assertion failure in -[NSMenu insertItemWithTitle:action:keyEquivalent:atIndex:], NSMenu.m:1185`


### Expected behavior

Expect toga.Selection to show all items in array if larger than 27. 

### Screenshots

<img width="1680" alt="Screenshot 2024-01-03 at 5 18 46 PM" src="https://github.com/beeware/toga/assets/135765199/f3823edd-1215-4de1-9928-a928da38ff5c">


### Environment

- Operating System: iOS
- Python version: 3.10.13
- Software versions:
  - Briefcase: 0.3.16
  - Toga: 0.4.1


### Logs

No logs available from this error.

### Additional context

_No response_


Discussion:
To clarify - are you seeing this on macOS, or iOS, or both? The report says iOS, but the screenshot is of `briefcase dev`, which will be using the macOS backend.
Oops! Mac 
Thanks - I've found the source of the problem. I'm not sure if this is bug in error handling or in the widget itself; at the very least, error handling should be improved.

The bug is entirely macOS specific; AFAICT, no other backend is affected.

The problem is with your data - it contains duplicates. There are 2 "a" entries and 2 "b" entries; the bug you're seeing is cause because the list of *unique* entries has less items than the list of entries. On macOS, Selection items are expected to be unique. When macOS encounters the first duplicate, it repositions the first original item to the end of the list; but at this point, the number of items in the data doesn't match the number of items in the selection, so the insertion of the second item fails because it exceeds the current size of the selection.

The immediate workaround - avoid duplicate titles in your selection data.

Longer term, I can see 2 possible fixes:

1. Raise an error if the data contains duplicate titles
2. Modify the implementation so it can display duplicate items.

All the other backends *allow* duplicate titles, which suggests that (2) is the fix required for macOS. [This SO post](https://stackoverflow.com/questions/2320059/how-to-add-multiple-menu-items-with-the-same-title-to-nspopupbuttonnsmenu) suggests a possible approach.

However, it's worth noting that allowing duplicated entries means you end up with selections with duplicated titles that can't be differentiated. At the very least, this should probably be raised as a warning, as it indicates a possible UX issue. 

For future reference, the part of this bug report that was helpful was the code sample included in the screenshot. The first part of any bug triage process is reproducing the bug; and that means having a working example. If you hadn't provided the screenshot of your code, I would have concluded this bug didn't exist, because the `selection` example app contains an example of a selection widget that has 72 entries. However, using as screenshot means I needed to manually type in parts of your code. Providing a fully working (but minimal) example is the most effective way to ensure someone can fix your bug.



Resolving patch:
diff --git a/cocoa/src/toga_cocoa/widgets/selection.py b/cocoa/src/toga_cocoa/widgets/selection.py
--- a/cocoa/src/toga_cocoa/widgets/selection.py
+++ b/cocoa/src/toga_cocoa/widgets/selection.py
@@ -33,10 +33,16 @@ def rehint(self):
         )
 
     def insert(self, index, item):
+        # Issue 2319 - if item titles are not unique, macOS will move the existing item,
+        # rather than creating a duplicate item. To work around this, create an item
+        # with a temporary but unique name, then change the name. `_title_for_item()`
+        # can't return newlines, so the use of a newline guarantees the name for the
+        # initial item is unique.
         self.native.insertItemWithTitle(
-            self.interface._title_for_item(item),
+            "\n<temp>",
             atIndex=index,
         )
+        self.native.itemAtIndex(index).title = self.interface._title_for_item(item)
 
         # If this is the first time item in the list, it will be automatically
         # selected; trigger a change event.
diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py
--- a/core/src/toga/widgets/selection.py
+++ b/core/src/toga/widgets/selection.py
@@ -120,7 +120,7 @@ def _title_for_item(self, item):
         else:
             title = item.value
 
-        return str(title)
+        return str(title).split("\n")[0]
 
     @property
     def value(self):
diff --git a/examples/selection/selection/app.py b/examples/selection/selection/app.py
--- a/examples/selection/selection/app.py
+++ b/examples/selection/selection/app.py
@@ -70,7 +70,14 @@ def startup(self):
                         ),
                         toga.Selection(
                             on_change=self.my_on_change,
-                            items=["Dubnium", "Holmium", "Zirconium"],
+                            items=[
+                                "Dubnium",
+                                "Holmium",
+                                "Zirconium",
+                                "Dubnium",
+                                "Holmium",
+                                "Zirconium",
+                            ],
                         ),
                     ],
                 ),


Test patch:
diff --git a/core/tests/widgets/test_selection.py b/core/tests/widgets/test_selection.py
--- a/core/tests/widgets/test_selection.py
+++ b/core/tests/widgets/test_selection.py
@@ -73,12 +73,12 @@ def test_create_with_value():
 
 
 @pytest.mark.parametrize(
-    "items, value",
+    "items, title, value",
     [
         # list of strings
-        (["first", "second", "third"], "second"),
+        (["first", "second", "third"], "second", "second"),
         # list of non-strings
-        ([111, 222, 333], 222),
+        ([111, 222, 333], "222", 222),
         # List of dictionaries; implied use of "value" key
         (
             [
@@ -86,6 +86,7 @@ def test_create_with_value():
                 {"key": "second", "value": 222},
                 {"key": "third", "value": 333},
             ],
+            "222",
             222,
         ),
         # List of tuples; only the first item is used.
@@ -96,10 +97,15 @@ def test_create_with_value():
                 ("third", 333),
             ],
             "second",
+            "second",
         ),
+        # List of strings with a newline in value
+        (["first", "second\nitem", "third"], "second", "second\nitem"),
+        # List of strings with a duplicate entry
+        (["first", "second", "third", "first", "second"], "second", "second"),
     ],
 )
-def test_value_no_accessor(items, value):
+def test_value_no_accessor(items, title, value):
     "If there's no accessor, the items can be set and values will be dereferenced"
     on_change_handler = Mock()
     widget = toga.Selection(on_change=on_change_handler)
@@ -124,17 +130,17 @@ def test_value_no_accessor(items, value):
     assert widget.value == value
 
     # The value renders as a string
-    assert widget._title_for_item(widget.items[1]) == str(value)
+    assert widget._title_for_item(widget.items[1]) == title
 
     # Change handler was invoked
     on_change_handler.assert_called_once_with(widget)
 
 
 @pytest.mark.parametrize(
-    "accessor, items, value",
+    "accessor, items, title, value",
     [
         # List of strings with a custom accessor name
-        ("key", ["first", "second", "third"], "second"),
+        ("key", ["first", "second", "third"], "second", "second"),
         # List of dictionaries selecting non-default key
         (
             "key",
@@ -144,6 +150,7 @@ def test_value_no_accessor(items, value):
                 {"key": "third", "value": 333},
             ],
             "second",
+            "second",
         ),
         # List of tuples; accessor name is altered.
         (
@@ -154,6 +161,7 @@ def test_value_no_accessor(items, value):
                 ("third", 333),
             ],
             "second",
+            "second",
         ),
         (
             "key",
@@ -166,10 +174,15 @@ def test_value_no_accessor(items, value):
                 ],
             ),
             "second",
+            "second",
         ),
+        # List of strings with a newline in a title
+        ("key", ["first", "second\nitem", "third"], "second", "second\nitem"),
+        # List of strings with a duplicate entry
+        ("key", ["first", "second", "third", "first", "second"], "second", "second"),
     ],
 )
-def test_value_with_accessor(accessor, items, value):
+def test_value_with_accessor(accessor, items, title, value):
     "If an accessor is used, item lookup semantics are different"
     on_change_handler = Mock()
     widget = toga.Selection(accessor=accessor, on_change=on_change_handler)
@@ -195,7 +208,7 @@ def test_value_with_accessor(accessor, items, value):
     assert getattr(widget.value, accessor) == value
 
     # The value renders as a string
-    assert widget._title_for_item(widget.items[1]) == str(value)
+    assert widget._title_for_item(widget.items[1]) == title
 
     # Change handler was invoked
     on_change_handler.assert_called_once_with(widget)
diff --git a/testbed/tests/widgets/test_selection.py b/testbed/tests/widgets/test_selection.py
--- a/testbed/tests/widgets/test_selection.py
+++ b/testbed/tests/widgets/test_selection.py
@@ -91,6 +91,20 @@ async def test_item_titles(widget, probe):
                 111,
                 "111",
             ),
+            # List of strings with newlines
+            (
+                ["first\nitem", "second\nitem", "third\nitem"],
+                ["first", "second", "third"],
+                "first\nitem",
+                "first",
+            ),
+            # List of strings with duplicates
+            (
+                ["first", "second", "third", "first", "second"],
+                ["first", "second", "third", "first", "second"],
+                "first",
+                "first",
+            ),
         ],
         start=1,
     ):