Repository: Textualize/textual

Issue:
Add `remove` attribute to `mount` and `mount_all`
It's common to remove some widgets and add some other widgets. To make this easier, I think we should add a `remove` attribute to the mount methods which accepts a selector and removes those widgets prior to mounting.

The remove + mount should be atomic, to avoid flicker (might need to be wrapped in `batch_update`).

API would look something like this:

```python
# Replace all MyWidgets with a single MyWidget
await self.mount(MyWidget("Foo"), remove="MyWidget")

# Remove all children and replace with a label
await self.mount(Label("Goodbye"), remove="*")
```


Discussion:
I think of the method `mount` as a method that “builds”, “adds”, “constructs”.
Adding a kwd argument that goes in the opposite direction feels a bit clunky.

Wouldn't it make more sense to grow a method `replace(selector, new_widget)`?
The thing is that it is not replacing. It's not like we can guarantee the widgets will be mounted in the same place as the "remove" selector.
In that case, call it `remove_then_mount`.
Or, better yet, let `Widget.remove_children` grow the `selector` parameter that is `"*"` by default but that you can change.
I think

```py
self.remove_children(selector="...")
self.mount(widget)
```

looks much better than

```py
self.mount(widget, remove="...")
```

The thing with `remove` inside `mount` is that people will think that the parameter `remove` is somehow _related_ to the mounting when it's completely orthogonal.
Two methods aren't atomic. Which means you would have to wrap both calls in an asyncio Lock, and await them both. You would also need to use `batch_update` to avoid flicker. So now your code will look like this:

```python
async with self.lock:
    with self.app.batch_update():
        await self.remove_children("*")
        await self.mount(widget)
```

That's too much to ask of devs.

The `remove` attribute on `mount` is still my favorite. People misunderstanding the extra parameter can be alleviated by docs.
If you prefer the `mount(..., remove=selector)` over `remove_then_mount`, sure.
After a discussion in the office. We could combine the locking and batch update in to a single context manager.

```python
async with self.batch():
    await self.remove_children("*")
    await self.mount(widget)
```

That feels a little less clumsy, but it has its own flaw: Namely, that it requires async. You wouldn't be able to have optional awaitables.

Resolving patch:
diff --git a/src/textual/widget.py b/src/textual/widget.py
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -6,11 +6,13 @@
 
 from asyncio import Lock, create_task, wait
 from collections import Counter
+from contextlib import asynccontextmanager
 from fractions import Fraction
 from itertools import islice
 from types import TracebackType
 from typing import (
     TYPE_CHECKING,
+    AsyncGenerator,
     Awaitable,
     ClassVar,
     Collection,
@@ -53,6 +55,8 @@
 from .await_remove import AwaitRemove
 from .box_model import BoxModel
 from .cache import FIFOCache
+from .css.match import match
+from .css.parse import parse_selectors
 from .css.query import NoMatches, WrongType
 from .css.scalar import ScalarOffset
 from .dom import DOMNode, NoScreen
@@ -78,6 +82,7 @@
 
 if TYPE_CHECKING:
     from .app import App, ComposeResult
+    from .css.query import QueryType
     from .message_pump import MessagePump
     from .scrollbar import (
         ScrollBar,
@@ -3291,15 +3296,42 @@ def remove(self) -> AwaitRemove:
         await_remove = self.app._remove_nodes([self], self.parent)
         return await_remove
 
-    def remove_children(self) -> AwaitRemove:
-        """Remove all children of this Widget from the DOM.
+    def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove:
+        """Remove the immediate children of this Widget from the DOM.
+
+        Args:
+            selector: A CSS selector to specify which direct children to remove.
 
         Returns:
-            An awaitable object that waits for the children to be removed.
+            An awaitable object that waits for the direct children to be removed.
         """
-        await_remove = self.app._remove_nodes(list(self.children), self)
+        if not isinstance(selector, str):
+            selector = selector.__name__
+        parsed_selectors = parse_selectors(selector)
+        children_to_remove = [
+            child for child in self.children if match(parsed_selectors, child)
+        ]
+        await_remove = self.app._remove_nodes(children_to_remove, self)
         return await_remove
 
+    @asynccontextmanager
+    async def batch(self) -> AsyncGenerator[None, None]:
+        """Async context manager that combines widget locking and update batching.
+
+        Use this async context manager whenever you want to acquire the widget lock and
+        batch app updates at the same time.
+
+        Example:
+            ```py
+            async with container.batch():
+                await container.remove_children(Button)
+                await container.mount(Label("All buttons are gone."))
+            ```
+        """
+        async with self.lock:
+            with self.app.batch_update():
+                yield
+
     def render(self) -> RenderableType:
         """Get text or Rich renderable for this widget.
 


Test patch:
diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py
--- a/tests/test_widget_removing.py
+++ b/tests/test_widget_removing.py
@@ -141,6 +141,63 @@ async def test_widget_remove_children_container():
         assert len(container.children) == 0
 
 
+async def test_widget_remove_children_with_star_selector():
+    app = ExampleApp()
+    async with app.run_test():
+        container = app.query_one(Vertical)
+
+        # 6 labels in total, with 5 of them inside the container.
+        assert len(app.query(Label)) == 6
+        assert len(container.children) == 5
+
+        await container.remove_children("*")
+
+        # The labels inside the container are gone, and the 1 outside remains.
+        assert len(app.query(Label)) == 1
+        assert len(container.children) == 0
+
+
+async def test_widget_remove_children_with_string_selector():
+    app = ExampleApp()
+    async with app.run_test():
+        container = app.query_one(Vertical)
+
+        # 6 labels in total, with 5 of them inside the container.
+        assert len(app.query(Label)) == 6
+        assert len(container.children) == 5
+
+        await app.screen.remove_children("Label")
+
+        # Only the Screen > Label widget is gone, everything else remains.
+        assert len(app.query(Button)) == 1
+        assert len(app.query(Vertical)) == 1
+        assert len(app.query(Label)) == 5
+
+
+async def test_widget_remove_children_with_type_selector():
+    app = ExampleApp()
+    async with app.run_test():
+        assert len(app.query(Button)) == 1  # Sanity check.
+        await app.screen.remove_children(Button)
+        assert len(app.query(Button)) == 0
+
+
+async def test_widget_remove_children_with_selector_does_not_leak():
+    app = ExampleApp()
+    async with app.run_test():
+        container = app.query_one(Vertical)
+
+        # 6 labels in total, with 5 of them inside the container.
+        assert len(app.query(Label)) == 6
+        assert len(container.children) == 5
+
+        await container.remove_children("Label")
+
+        # The labels inside the container are gone, and the 1 outside remains.
+        assert len(app.query(Label)) == 1
+        assert len(container.children) == 0
+
+
 async def test_widget_remove_children_no_children():
     app = ExampleApp()
     async with app.run_test():
@@ -154,3 +211,17 @@ async def test_widget_remove_children_no_children():
         assert (
             count_before == count_after
         )  # No widgets have been removed, since Button has no children.
+
+
+async def test_widget_remove_children_no_children_match_selector():
+    app = ExampleApp()
+    async with app.run_test():
+        container = app.query_one(Vertical)
+        assert len(container.query("Button")) == 0  # Sanity check.
+
+        count_before = len(app.query("*"))
+        container_children_before = list(container.children)
+        await container.remove_children("Button")
+
+        assert count_before == len(app.query("*"))
+        assert container_children_before == list(container.children)