Skip to content

Commit 9561d47

Browse files
Merge pull request #4183 from Textualize/batch-async-context-manager
Adds a `Widget.batch` async context manager
2 parents ba17dfb + 9f5e653 commit 9561d47

File tree

3 files changed

+110
-4
lines changed

3 files changed

+110
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1818
- Add attribute `App.animation_level` to control whether animations on that app run or not https://github.com/Textualize/textual/pull/4062
1919
- Added support for a `TEXTUAL_SCREENSHOT_LOCATION` environment variable to specify the location of an automated screenshot https://github.com/Textualize/textual/pull/4181/
2020
- Added support for a `TEXTUAL_SCREENSHOT_FILENAME` environment variable to specify the filename of an automated screenshot https://github.com/Textualize/textual/pull/4181/
21+
- Added an `asyncio` lock attribute `Widget.lock` to be used to synchronize widget state https://github.com/Textualize/textual/issues/4134
22+
- `Widget.remove_children` now accepts a CSS selector to specify which children to remove https://github.com/Textualize/textual/pull/4183
23+
- `Widget.batch` combines widget locking and app update batching https://github.com/Textualize/textual/pull/4183
2124

2225
## [0.51.0] - 2024-02-15
2326

src/textual/widget.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66

77
from asyncio import Lock, create_task, wait
88
from collections import Counter
9+
from contextlib import asynccontextmanager
910
from fractions import Fraction
1011
from itertools import islice
1112
from types import TracebackType
1213
from typing import (
1314
TYPE_CHECKING,
15+
AsyncGenerator,
1416
Awaitable,
1517
ClassVar,
1618
Collection,
@@ -53,6 +55,8 @@
5355
from .await_remove import AwaitRemove
5456
from .box_model import BoxModel
5557
from .cache import FIFOCache
58+
from .css.match import match
59+
from .css.parse import parse_selectors
5660
from .css.query import NoMatches, WrongType
5761
from .css.scalar import ScalarOffset
5862
from .dom import DOMNode, NoScreen
@@ -78,6 +82,7 @@
7882

7983
if TYPE_CHECKING:
8084
from .app import App, ComposeResult
85+
from .css.query import QueryType
8186
from .message_pump import MessagePump
8287
from .scrollbar import (
8388
ScrollBar,
@@ -3291,15 +3296,42 @@ def remove(self) -> AwaitRemove:
32913296
await_remove = self.app._remove_nodes([self], self.parent)
32923297
return await_remove
32933298

3294-
def remove_children(self) -> AwaitRemove:
3295-
"""Remove all children of this Widget from the DOM.
3299+
def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove:
3300+
"""Remove the immediate children of this Widget from the DOM.
3301+
3302+
Args:
3303+
selector: A CSS selector to specify which direct children to remove.
32963304
32973305
Returns:
3298-
An awaitable object that waits for the children to be removed.
3306+
An awaitable object that waits for the direct children to be removed.
32993307
"""
3300-
await_remove = self.app._remove_nodes(list(self.children), self)
3308+
if not isinstance(selector, str):
3309+
selector = selector.__name__
3310+
parsed_selectors = parse_selectors(selector)
3311+
children_to_remove = [
3312+
child for child in self.children if match(parsed_selectors, child)
3313+
]
3314+
await_remove = self.app._remove_nodes(children_to_remove, self)
33013315
return await_remove
33023316

3317+
@asynccontextmanager
3318+
async def batch(self) -> AsyncGenerator[None, None]:
3319+
"""Async context manager that combines widget locking and update batching.
3320+
3321+
Use this async context manager whenever you want to acquire the widget lock and
3322+
batch app updates at the same time.
3323+
3324+
Example:
3325+
```py
3326+
async with container.batch():
3327+
await container.remove_children(Button)
3328+
await container.mount(Label("All buttons are gone."))
3329+
```
3330+
"""
3331+
async with self.lock:
3332+
with self.app.batch_update():
3333+
yield
3334+
33033335
def render(self) -> RenderableType:
33043336
"""Get text or Rich renderable for this widget.
33053337

tests/test_widget_removing.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,63 @@ async def test_widget_remove_children_container():
141141
assert len(container.children) == 0
142142

143143

144+
async def test_widget_remove_children_with_star_selector():
145+
app = ExampleApp()
146+
async with app.run_test():
147+
container = app.query_one(Vertical)
148+
149+
# 6 labels in total, with 5 of them inside the container.
150+
assert len(app.query(Label)) == 6
151+
assert len(container.children) == 5
152+
153+
await container.remove_children("*")
154+
155+
# The labels inside the container are gone, and the 1 outside remains.
156+
assert len(app.query(Label)) == 1
157+
assert len(container.children) == 0
158+
159+
160+
async def test_widget_remove_children_with_string_selector():
161+
app = ExampleApp()
162+
async with app.run_test():
163+
container = app.query_one(Vertical)
164+
165+
# 6 labels in total, with 5 of them inside the container.
166+
assert len(app.query(Label)) == 6
167+
assert len(container.children) == 5
168+
169+
await app.screen.remove_children("Label")
170+
171+
# Only the Screen > Label widget is gone, everything else remains.
172+
assert len(app.query(Button)) == 1
173+
assert len(app.query(Vertical)) == 1
174+
assert len(app.query(Label)) == 5
175+
176+
177+
async def test_widget_remove_children_with_type_selector():
178+
app = ExampleApp()
179+
async with app.run_test():
180+
assert len(app.query(Button)) == 1 # Sanity check.
181+
await app.screen.remove_children(Button)
182+
assert len(app.query(Button)) == 0
183+
184+
185+
async def test_widget_remove_children_with_selector_does_not_leak():
186+
app = ExampleApp()
187+
async with app.run_test():
188+
container = app.query_one(Vertical)
189+
190+
# 6 labels in total, with 5 of them inside the container.
191+
assert len(app.query(Label)) == 6
192+
assert len(container.children) == 5
193+
194+
await container.remove_children("Label")
195+
196+
# The labels inside the container are gone, and the 1 outside remains.
197+
assert len(app.query(Label)) == 1
198+
assert len(container.children) == 0
199+
200+
144201
async def test_widget_remove_children_no_children():
145202
app = ExampleApp()
146203
async with app.run_test():
@@ -154,3 +211,17 @@ async def test_widget_remove_children_no_children():
154211
assert (
155212
count_before == count_after
156213
) # No widgets have been removed, since Button has no children.
214+
215+
216+
async def test_widget_remove_children_no_children_match_selector():
217+
app = ExampleApp()
218+
async with app.run_test():
219+
container = app.query_one(Vertical)
220+
assert len(container.query("Button")) == 0 # Sanity check.
221+
222+
count_before = len(app.query("*"))
223+
container_children_before = list(container.children)
224+
await container.remove_children("Button")
225+
226+
assert count_before == len(app.query("*"))
227+
assert container_children_before == list(container.children)

0 commit comments

Comments
 (0)