Repository: beeware/toga

Issue:
Add API to replace children
### What is the problem or limitation you are having?

Currently, if I use a multi-pages design and create multiple pages (multiple boxes containing multiple widgets), and I want to switch between these pages — between page1_box and page2_box, for example — I need to:
```
main_box.remove(page1_box)
main_box.insert(pos, page2_box)
```

So, not only it's 2 steps to do 1 thing (swap out/replace a child), but I also need to know the right index for the insert, which seems cumbersome to keep track of if I plan on adding/removing a few widgets during runtime.

### Describe the solution you'd like

Add a `.replace()` method to nodes, that will join the likes of `.remove()` .`add()` and `.insert()`, so that we can:
1. Replace a child node in one step;
2. Not have to worry about index numbers.

Example:
`main_box.replace(page1_box, page2_box)`

In other words:
`Box.replace(old_child, new_child)`

This pattern follows the way Python's string replace works — `str.replace(old, new)`.

### Describe alternatives you've considered

Add method to replace its calling node.
`page1_box.replace(page2_box)`

In other words:
`old_child.replace(new_child)`

But this seems to break the existing convention with add, remove and insert.

### Additional context

_No response_


Discussion:
I liked the proposal, I'm trying to do something similar, but I hadn't found anything related to this page design in the documentation, I think this proposal would help my project too
I have *some* hesitation about this proposal - it seems to be predicated on the assumption that widget containers are "string like". I think it's a lot more reasonable to consider them to be "list like" - if only because of the `"hoodoo".replace("o", "e")` example. 

That said, I accept that "replacing X with Y" is a common use case, and the multiple-replace case can't happen, so there's no risk in using the same API name.

As part of any implementation, we should also add an `index()` method, which returns the current position of a widget in the list of children. We'll need an `index()` method in order to implement `replace()` anyway, so we might as well expose the capability. 
> it seems to be predicated on the assumption that widget containers are "string like". I think it's a lot more reasonable to consider them to be "list like" 

In that case, instead of `replace`, the API should use square bracket syntax via `__getitem__` and `__setitem__`.

EDIT:  Well, I suppose we could have them both, as they're useful in different situations.
Agreed that `__getitem__`/`__setitem__` syntax (in addition to `replace`) would also make sense.
I'm taking a look at this at the PyCon US sprint

Resolving patch:
diff --git a/core/src/toga/widgets/base.py b/core/src/toga/widgets/base.py
--- a/core/src/toga/widgets/base.py
+++ b/core/src/toga/widgets/base.py
@@ -140,6 +140,30 @@ def insert(self, index: int, child: Widget) -> None:
         # Whatever layout we're a part of needs to be refreshed
         self.refresh()
 
+    def index(self, child: Widget) -> int:
+        """Get the index of a widget in the list of children of this widget.
+
+        :param child: The child widget of interest.
+        :raises ValueError: If the specified child widget is not found in the
+            list of children.
+
+        :returns: Index of specified child widget in children list.
+        """
+        for _ind, _child in enumerate(self._children):
+            if child == _child:
+                return _ind
+        raise ValueError(f"{type(child).__name__} not found")
+
+    def replace(self, old_child: Widget, new_child: Widget) -> None:
+        """Replace an existing child widget with a new child widget.
+
+        :param old_child: The existing child widget to be replaced.
+        :param new_child: The new child widget to be included.
+        """
+        old_child_index = self.index(old_child)
+        self.remove(old_child)
+        self.insert(old_child_index, new_child)
+
     def remove(self, *children: Widget) -> None:
         """Remove the provided widgets as children of this node.
 


Test patch:
diff --git a/core/tests/widgets/test_base.py b/core/tests/widgets/test_base.py
--- a/core/tests/widgets/test_base.py
+++ b/core/tests/widgets/test_base.py
@@ -163,6 +163,65 @@ def test_add_child(app, widget):
     assert app.widgets["child_id"] == child
 
 
+def test_child_square_bracket_access(widget):
+    """Assign and get individual children via square bracket notation."""
+    child1 = ExampleLeafWidget(id="child1_id")
+    child2 = ExampleLeafWidget(id="child2_id")
+
+    # Add child to widget
+    widget.add(child1)
+
+    # Verify child has been added
+    assert widget.children == [child1]
+
+    # Assign child via __setitem__
+    widget.children[0] = child2
+
+    # Verify new child is in children via __getitem__
+    assert widget.children[0] == child2
+
+
+def test_get_index_of_children(widget):
+    """Populate a widget with children and check the index method."""
+    child1 = ExampleLeafWidget(id="child1_id")
+    child2 = ExampleLeafWidget(id="child2_id")
+    child3 = ExampleLeafWidget(id="child3_id")
+
+    # Add only two children to widget
+    widget.add(child1)
+    widget.add(child2)
+
+    # Verify children have been added
+    assert widget.children == [child1, child2]
+
+    # Get indices of valid children
+    assert widget.index(child1) == 0
+    assert widget.index(child2) == 1
+
+    # Raise error when child not found in children
+    with pytest.raises(ValueError, match=r"ExampleLeafWidget not found"):
+        widget.index(child3)
+
+
+def test_replace_child_with_non_child(widget):
+    """Remove a child and add new widget in its place."""
+    child1 = ExampleLeafWidget(id="child1_id")
+    child2 = ExampleLeafWidget(id="child2_id")
+    child3 = ExampleLeafWidget(id="child3_id")
+
+    # Add only two children to widget
+    widget.add(child1)
+    widget.add(child2)
+
+    # Verify children have been added
+    assert widget.children == [child1, child2]
+
+    widget.replace(child1, child3)
+
+    # Verify child1 has been removed and child3 has been added
+    assert widget.children == [child3, child2]
+
+
 def test_add_multiple_children(app, widget):
     """Multiple children can be added in one call."""
     # Set the app and window for the widget.