Skip to content

Commit 8ca3a1b

Browse files
authored
Merge pull request #5562 from Textualize/app-query-change
App query change
2 parents f7d2be1 + 8a6cdf6 commit 8ca3a1b

16 files changed

+89
-74
lines changed

CHANGELOG.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
77

88
## Unreleased
99

10+
11+
### Changed
12+
13+
- Breaking change: `App.query` and friends will now always query the default (first) screen, not necessarily the active screen.
14+
- Content now has a default argument of an empty string, so `Content()` is equivalent to `Content("")`
15+
- Assigned names to Textual-specific threads: `textual-input`, `textual-output`. These should become visible in monitoring tools (ps, top, htop) as of Python 3.14. https://github.com/Textualize/textual/pull/5654
16+
- Tabs now accept Content or content markup https://github.com/Textualize/textual/pull/5657
17+
- Buttons will now use Textual markup rather than console markup
18+
- tree-sitter languages are now loaded lazily, improving cold-start time https://github.com/Textualize/textual/pull/563
19+
1020
### Fixed
1121

1222
- Static and Label now accept Content objects, satisfying type checkers https://github.com/Textualize/textual/pull/5618
@@ -25,14 +35,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2535
- Added `Content.empty` constructor https://github.com/Textualize/textual/pull/5657
2636
- Added `Content.pad` method https://github.com/Textualize/textual/pull/5657
2737
- Added `Style.has_transparent_foreground` property https://github.com/Textualize/textual/pull/5657
28-
- DOMNode.query now accepts UnionType for selector in Python 3.10 and above, e.g. `self.query(Input | Select )` https://github.com/Textualize/textual/pull/5578
29-
30-
## Changed
3138

32-
- Assigned names to Textual-specific threads: `textual-input`, `textual-output`. These should become visible in monitoring tools (ps, top, htop) as of Python 3.14. https://github.com/Textualize/textual/pull/5654
33-
- Tabs now accept Content or content markup https://github.com/Textualize/textual/pull/5657
34-
- Buttons will now use Textual markup rather than console markup
35-
- tree-sitter languages are now loaded lazily, improving cold-start time https://github.com/Textualize/textual/pull/5639
3639

3740
## [2.1.2] - 2025-02-26
3841

src/textual/app.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,9 @@ def __init__(
798798
self.supports_smooth_scrolling: bool = False
799799
"""Does the terminal support smooth scrolling?"""
800800

801+
self._compose_screen: Screen | None = None
802+
"""The screen composed by App.compose."""
803+
801804
if self.ENABLE_COMMAND_PALETTE:
802805
for _key, binding in self._bindings:
803806
if binding.action in {"command_palette", "app.command_palette"}:
@@ -833,6 +836,10 @@ def __init_subclass__(cls, *args, **kwargs) -> None:
833836

834837
return super().__init_subclass__(*args, **kwargs)
835838

839+
def _get_dom_base(self) -> DOMNode:
840+
"""When querying from the app, we want to query the default screen."""
841+
return self.default_screen
842+
836843
def validate_title(self, title: Any) -> str:
837844
"""Make sure the title is set to a string."""
838845
return str(title)
@@ -841,6 +848,11 @@ def validate_sub_title(self, sub_title: Any) -> str:
841848
"""Make sure the subtitle is set to a string."""
842849
return str(sub_title)
843850

851+
@property
852+
def default_screen(self) -> Screen:
853+
"""The default screen instance."""
854+
return self.screen if self._compose_screen is None else self._compose_screen
855+
844856
@property
845857
def workers(self) -> WorkerManager:
846858
"""The [worker](/guide/workers/) manager.
@@ -3244,6 +3256,7 @@ async def take_screenshot() -> None:
32443256

32453257
async def _on_compose(self) -> None:
32463258
_rich_traceback_omit = True
3259+
self._compose_screen = self.screen
32473260
try:
32483261
widgets = [*self.screen._nodes, *compose(self)]
32493262
except TypeError as error:

src/textual/content.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ class Content(Visual):
122122

123123
def __init__(
124124
self,
125-
text: str,
125+
text: str = "",
126126
spans: list[Span] | None = None,
127127
cell_length: int | None = None,
128128
) -> None:

src/textual/dom.py

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,17 @@ def __init__(
235235

236236
super().__init__()
237237

238+
def _get_dom_base(self) -> DOMNode:
239+
"""Get the DOM base node (typically self).
240+
241+
All DOM queries on this node will use the return value as the root node.
242+
This method allows the App to query the default screen, and not the active screen.
243+
244+
Returns:
245+
DOMNode.
246+
"""
247+
return self
248+
238249
def set_reactive(
239250
self, reactive: Reactive[ReactiveType], value: ReactiveType
240251
) -> None:
@@ -1380,10 +1391,11 @@ def query(
13801391
from textual.css.query import DOMQuery, QueryType
13811392
from textual.widget import Widget
13821393

1394+
node = self._get_dom_base()
13831395
if isinstance(selector, str) or selector is None:
1384-
return DOMQuery[Widget](self, filter=selector)
1396+
return DOMQuery[Widget](node, filter=selector)
13851397
else:
1386-
return DOMQuery[QueryType](self, filter=selector.__name__)
1398+
return DOMQuery[QueryType](node, filter=selector.__name__)
13871399

13881400
if TYPE_CHECKING:
13891401

@@ -1411,10 +1423,11 @@ def query_children(
14111423
from textual.css.query import DOMQuery, QueryType
14121424
from textual.widget import Widget
14131425

1426+
node = self._get_dom_base()
14141427
if isinstance(selector, str) or selector is None:
1415-
return DOMQuery[Widget](self, deep=False, filter=selector)
1428+
return DOMQuery[Widget](node, deep=False, filter=selector)
14161429
else:
1417-
return DOMQuery[QueryType](self, deep=False, filter=selector.__name__)
1430+
return DOMQuery[QueryType](node, deep=False, filter=selector.__name__)
14181431

14191432
if TYPE_CHECKING:
14201433

@@ -1449,6 +1462,8 @@ def query_one(
14491462
"""
14501463
_rich_traceback_omit = True
14511464

1465+
base_node = self._get_dom_base()
1466+
14521467
if isinstance(selector, str):
14531468
query_selector = selector
14541469
else:
@@ -1462,20 +1477,20 @@ def query_one(
14621477
) from None
14631478

14641479
if all(selectors.is_simple for selectors in selector_set):
1465-
cache_key = (self._nodes._updates, query_selector, expect_type)
1466-
cached_result = self._query_one_cache.get(cache_key)
1480+
cache_key = (base_node._nodes._updates, query_selector, expect_type)
1481+
cached_result = base_node._query_one_cache.get(cache_key)
14671482
if cached_result is not None:
14681483
return cached_result
14691484
else:
14701485
cache_key = None
14711486

1472-
for node in walk_depth_first(self, with_root=False):
1487+
for node in walk_depth_first(base_node, with_root=False):
14731488
if not match(selector_set, node):
14741489
continue
14751490
if expect_type is not None and not isinstance(node, expect_type):
14761491
continue
14771492
if cache_key is not None:
1478-
self._query_one_cache[cache_key] = node
1493+
base_node._query_one_cache[cache_key] = node
14791494
return node
14801495

14811496
raise NoMatches(f"No nodes match {selector!r} on {self!r}")
@@ -1518,6 +1533,8 @@ def query_exactly_one(
15181533
"""
15191534
_rich_traceback_omit = True
15201535

1536+
base_node = self._get_dom_base()
1537+
15211538
if isinstance(selector, str):
15221539
query_selector = selector
15231540
else:
@@ -1531,14 +1548,14 @@ def query_exactly_one(
15311548
) from None
15321549

15331550
if all(selectors.is_simple for selectors in selector_set):
1534-
cache_key = (self._nodes._updates, query_selector, expect_type)
1535-
cached_result = self._query_one_cache.get(cache_key)
1551+
cache_key = (base_node._nodes._updates, query_selector, expect_type)
1552+
cached_result = base_node._query_one_cache.get(cache_key)
15361553
if cached_result is not None:
15371554
return cached_result
15381555
else:
15391556
cache_key = None
15401557

1541-
children = walk_depth_first(self, with_root=False)
1558+
children = walk_depth_first(base_node, with_root=False)
15421559
iter_children = iter(children)
15431560
for node in iter_children:
15441561
if not match(selector_set, node):
@@ -1553,7 +1570,7 @@ def query_exactly_one(
15531570
"Call to query_one resulted in more than one matched node"
15541571
)
15551572
if cache_key is not None:
1556-
self._query_one_cache[cache_key] = node
1573+
base_node._query_one_cache[cache_key] = node
15571574
return node
15581575

15591576
raise NoMatches(f"No nodes match {selector!r} on {self!r}")
@@ -1589,6 +1606,7 @@ def query_ancestor(
15891606
Returns:
15901607
A DOMNode or subclass if `expect_type` is provided.
15911608
"""
1609+
base_node = self._get_dom_base()
15921610
if isinstance(selector, str):
15931611
query_selector = selector
15941612
else:
@@ -1600,8 +1618,8 @@ def query_ancestor(
16001618
raise InvalidQueryFormat(
16011619
f"Unable to parse {query_selector!r} as a query; check for syntax errors"
16021620
) from None
1603-
if self.parent is not None:
1604-
for node in self.parent.ancestors_with_self:
1621+
if base_node.parent is not None:
1622+
for node in base_node.parent.ancestors_with_self:
16051623
if not match(selector_set, node):
16061624
continue
16071625
if expect_type is not None and not isinstance(node, expect_type):

src/textual/pilot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ async def _post_mouse_events(
414414
elif isinstance(widget, Widget):
415415
target_widget = widget
416416
else:
417-
target_widget = app.query_one(widget)
417+
target_widget = app.screen.query_one(widget)
418418

419419
message_arguments = _get_mouse_message_arguments(
420420
target_widget,

src/textual/scrollbar.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,6 @@ class ScrollBarCorner(Widget):
394394
"""Widget which fills the gap between horizontal and vertical scrollbars,
395395
should they both be present."""
396396

397-
def __init__(self, name: str | None = None):
398-
super().__init__(name=name)
399-
400397
def render(self) -> RenderableType:
401398
assert self.parent is not None
402399
styles = self.parent.styles

tests/css/test_screen_css.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ def on_mount(self):
5858

5959

6060
def check_colors_before_screen_css(app: BaseApp):
61-
assert app.query_one("#app-css").styles.background == GREEN
62-
assert app.query_one("#screen-css-path").styles.background == GREEN
63-
assert app.query_one("#screen-css").styles.background == GREEN
61+
assert app.screen.query_one("#app-css").styles.background == GREEN
62+
assert app.screen.query_one("#screen-css-path").styles.background == GREEN
63+
assert app.screen.query_one("#screen-css").styles.background == GREEN
6464

6565

6666
def check_colors_after_screen_css(app: BaseApp):
67-
assert app.query_one("#app-css").styles.background == GREEN
68-
assert app.query_one("#screen-css-path").styles.background == BLUE
69-
assert app.query_one("#screen-css").styles.background == RED
67+
assert app.screen.query_one("#app-css").styles.background == GREEN
68+
assert app.screen.query_one("#screen-css-path").styles.background == BLUE
69+
assert app.screen.query_one("#screen-css").styles.background == RED
7070

7171

7272
async def test_screen_pushing_and_popping_does_not_reparse_css():

tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ def compose(self) -> ComposeResult:
1111
yield Footer()
1212

1313
def on_mount(self) -> None:
14-
self.query_one("Screen").scroll_end()
14+
self.screen.scroll_end()
1515

1616

1717
app = ScrollOffByOne()

tests/test_compositor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def compose(self) -> ComposeResult:
3030
yield Static("Hello", id="hello")
3131

3232
def on_mount(self) -> None:
33-
self.query_one("Screen").scroll_to(20, 0, animate=False)
33+
self.screen.scroll_to(20, 0, animate=False)
3434

3535
app = ScrollApp()
3636
async with app.run_test() as pilot:

tests/test_header.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def on_mount(self):
1616

1717
app = MyApp()
1818
async with app.run_test():
19-
assert app.query_one("HeaderTitle").text == "app title"
19+
assert app.screen.query_one("HeaderTitle").text == "app title"
2020

2121

2222
async def test_screen_title_overrides_app_title():
@@ -34,7 +34,7 @@ def on_mount(self):
3434

3535
app = MyApp()
3636
async with app.run_test():
37-
assert app.query_one("HeaderTitle").text == "screen title"
37+
assert app.screen.query_one("HeaderTitle").text == "screen title"
3838

3939

4040
async def test_screen_title_reactive_updates_title():
@@ -54,7 +54,7 @@ def on_mount(self):
5454
async with app.run_test() as pilot:
5555
app.screen.title = "new screen title"
5656
await pilot.pause()
57-
assert app.query_one("HeaderTitle").text == "new screen title"
57+
assert app.screen.query_one("HeaderTitle").text == "new screen title"
5858

5959

6060
async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set():
@@ -74,7 +74,7 @@ def on_mount(self):
7474
async with app.run_test() as pilot:
7575
app.title = "new app title"
7676
await pilot.pause()
77-
assert app.query_one("HeaderTitle").text == "screen title"
77+
assert app.screen.query_one("HeaderTitle").text == "screen title"
7878

7979

8080
async def test_screen_sub_title_none_is_ignored():
@@ -90,7 +90,7 @@ def on_mount(self):
9090

9191
app = MyApp()
9292
async with app.run_test():
93-
assert app.query_one("HeaderTitle").sub_text == "app sub-title"
93+
assert app.screen.query_one("HeaderTitle").sub_text == "app sub-title"
9494

9595

9696
async def test_screen_sub_title_overrides_app_sub_title():
@@ -108,7 +108,7 @@ def on_mount(self):
108108

109109
app = MyApp()
110110
async with app.run_test():
111-
assert app.query_one("HeaderTitle").sub_text == "screen sub-title"
111+
assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title"
112112

113113

114114
async def test_screen_sub_title_reactive_updates_sub_title():
@@ -128,7 +128,7 @@ def on_mount(self):
128128
async with app.run_test() as pilot:
129129
app.screen.sub_title = "new screen sub-title"
130130
await pilot.pause()
131-
assert app.query_one("HeaderTitle").sub_text == "new screen sub-title"
131+
assert app.screen.query_one("HeaderTitle").sub_text == "new screen sub-title"
132132

133133

134134
async def test_app_sub_title_reactive_does_not_update_sub_title_when_screen_sub_title_is_set():
@@ -148,4 +148,4 @@ def on_mount(self):
148148
async with app.run_test() as pilot:
149149
app.sub_title = "new app sub-title"
150150
await pilot.pause()
151-
assert app.query_one("HeaderTitle").sub_text == "screen sub-title"
151+
assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title"

tests/test_pilot.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async def test_pilot_click_screen():
126126
Check we can use `Screen` as a selector for a click."""
127127

128128
async with App().run_test() as pilot:
129-
await pilot.click("Screen")
129+
await pilot.click()
130130

131131

132132
async def test_pilot_hover_screen():
@@ -135,7 +135,7 @@ async def test_pilot_hover_screen():
135135
Check we can use `Screen` as a selector for a hover."""
136136

137137
async with App().run_test() as pilot:
138-
await pilot.hover("Screen")
138+
await pilot.hover()
139139

140140

141141
@pytest.mark.parametrize(

tests/test_query.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,20 @@
1414
from textual.widgets import Input, Label
1515

1616

17-
def test_query_errors():
17+
async def test_query_errors():
1818
app = App()
19+
async with app.run_test():
20+
with pytest.raises(InvalidQueryFormat):
21+
app.query_one("foo_bar")
1922

20-
with pytest.raises(InvalidQueryFormat):
21-
app.query_one("foo_bar")
22-
23-
with pytest.raises(InvalidQueryFormat):
24-
app.query("foo_bar")
23+
with pytest.raises(InvalidQueryFormat):
24+
app.query("foo_bar")
2525

26-
with pytest.raises(InvalidQueryFormat):
27-
app.query("1")
26+
with pytest.raises(InvalidQueryFormat):
27+
app.query("1")
2828

29-
with pytest.raises(InvalidQueryFormat):
30-
app.query_one("1")
29+
with pytest.raises(InvalidQueryFormat):
30+
app.query_one("1")
3131

3232

3333
def test_query():

0 commit comments

Comments
 (0)