Repository: beeware/toga

Issue:
Add a `clear()` method to remove all children
### What is the problem or limitation you are having?

At present, you can:
* add a single child to a widget (`widget.add(child)`)
* remove a single child to a widget (`widget.remove(child)`)
* add multiple children to a widget in a single call (`widget.add(child1, child2)`)
* Remove multiple children from a widget in a single call  (`widget.remove(child1, child2)`)

However, there is no API to remove *all* children from a node.


### Describe the solution you'd like

`widget.clear()` should remove all children from a widget. As of Travertino 0.2.0, a `clear()` API exists; however, invoking it could have unexpected consequences, as it doesn't include the Toga-side cleanups implied by this change.

### Describe alternatives you've considered

`widget.remove(*widget.children)` is a workaround at present, but isn't a great API.

### Additional context

_No response_


Discussion:
I'd like to tackle this!  #pyconsprints2023

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
@@ -154,12 +154,17 @@ def remove(self, *children):
         Any nominated child widget that is not a child of this widget will
         not have any change in parentage.
 
+        Refreshes the widget after removal if any children were removed.
+
         Raises ``ValueError`` if this widget cannot have children.
 
         :param children: The child nodes to remove.
         """
+        removed = False
+
         for child in children:
             if child.parent is self:
+                removed = True
                 super().remove(child)
 
                 child.app = None
@@ -167,9 +172,18 @@ def remove(self, *children):
 
                 self._impl.remove_child(child._impl)
 
-        if self.window:
+        if self.window and removed:
             self.window.content.refresh()
 
+    def clear(self):
+        """Remove all child widgets of this node.
+
+        Refreshes the widget after removal if any children were removed.
+
+        Raises ``ValueError`` if this widget cannot have children.
+        """
+        self.remove(*self.children)
+
     @property
     def app(self):
         """The App to which this widget belongs.


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
@@ -617,6 +617,92 @@ def test_remove_multiple_children(widget):
     window.content.refresh.assert_called_once_with()
 
 
+def test_clear_all_children(widget):
+    "All children can be simultaneously removed from a widget"
+    # Add children to the widget
+    child1 = TestLeafWidget(id="child1_id")
+    child2 = TestLeafWidget(id="child2_id")
+    child3 = TestLeafWidget(id="child3_id")
+    widget.add(child1, child2, child3)
+
+    app = toga.App("Test", "com.example.test")
+    window = Mock()
+    widget.app = app
+    widget.window = window
+
+    assert widget.children == [child1, child2, child3]
+    for child in widget.children:
+        assert child.parent == widget
+        assert child.app == app
+        assert child.window == window
+
+    # Clear children
+    widget.clear()
+
+    # Parent doesn't know about the removed children, and vice versa
+    assert widget.children == []
+    assert child1.parent is None
+    assert child2.parent is None
+    assert child3.parent is None
+
+    # App and window have been reset on the removed widgets
+    assert child1.app is None
+    assert child1.window is None
+
+    assert child2.app is None
+    assert child2.window is None
+
+    assert child3.app is None
+    assert child3.window is None
+
+    # The impl's remove_child has been invoked thrice
+    assert_action_performed_with(widget, "remove child", child=child1._impl)
+    assert_action_performed_with(widget, "remove child", child=child2._impl)
+    assert_action_performed_with(widget, "remove child", child=child3._impl)
+
+    # The window layout has been refreshed once
+    window.content.refresh.assert_called_once_with()
+
+
+def test_clear_no_children(widget):
+    "No changes are made (no-op) if widget has no children"
+    app = toga.App("Test", "com.example.test")
+    window = Mock()
+    widget.app = app
+    widget.window = window
+
+    assert widget.children == []
+
+    # Clear children
+    widget.clear()
+
+    # Parent doesn't have any children still
+    assert widget.children == []
+
+    # The window layout has not been refreshed
+    window.content.refresh.assert_not_called()
+
+
+def test_clear_leaf_node():
+    "No changes are made to leaf node that cannot have children"
+    leaf = TestLeafWidget()
+    app = toga.App("Test", "com.example.test")
+    window = Mock()
+    leaf.app = app
+    leaf.window = window
+
+    assert leaf.children == []
+
+    # Clear children
+    leaf.clear()
+
+    # Parent doesn't have any children still
+    assert leaf.children == []
+
+    # The window layout has not been refreshed
+    window.content.refresh.assert_not_called()
+
+
 def test_remove_from_non_parent(widget):
     "Trying to remove a child from a widget other than it's parent is a no-op"
     # Create a second parent widget, and add a child to it