Skip to content

App query change #5562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased


### Changed

- Breaking change: `App.query` and friends will now always query the default (first) screen, not necessarily the active screen.
- Content now has a default argument of an empty string, so `Content()` is equivalent to `Content("")`
- 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
- Tabs now accept Content or content markup https://github.com/Textualize/textual/pull/5657
- Buttons will now use Textual markup rather than console markup
- tree-sitter languages are now loaded lazily, improving cold-start time https://github.com/Textualize/textual/pull/563

### Fixed

- Static and Label now accept Content objects, satisfying type checkers https://github.com/Textualize/textual/pull/5618
Expand All @@ -25,14 +35,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `Content.empty` constructor https://github.com/Textualize/textual/pull/5657
- Added `Content.pad` method https://github.com/Textualize/textual/pull/5657
- Added `Style.has_transparent_foreground` property https://github.com/Textualize/textual/pull/5657
- 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

## Changed

- 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
- Tabs now accept Content or content markup https://github.com/Textualize/textual/pull/5657
- Buttons will now use Textual markup rather than console markup
- tree-sitter languages are now loaded lazily, improving cold-start time https://github.com/Textualize/textual/pull/5639

## [2.1.2] - 2025-02-26

Expand Down
13 changes: 13 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,9 @@ def __init__(
self.supports_smooth_scrolling: bool = False
"""Does the terminal support smooth scrolling?"""

self._compose_screen: Screen | None = None
"""The screen composed by App.compose."""

if self.ENABLE_COMMAND_PALETTE:
for _key, binding in self._bindings:
if binding.action in {"command_palette", "app.command_palette"}:
Expand Down Expand Up @@ -833,6 +836,10 @@ def __init_subclass__(cls, *args, **kwargs) -> None:

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

def _get_dom_base(self) -> DOMNode:
"""When querying from the app, we want to query the default screen."""
return self.default_screen

def validate_title(self, title: Any) -> str:
"""Make sure the title is set to a string."""
return str(title)
Expand All @@ -841,6 +848,11 @@ def validate_sub_title(self, sub_title: Any) -> str:
"""Make sure the subtitle is set to a string."""
return str(sub_title)

@property
def default_screen(self) -> Screen:
"""The default screen instance."""
return self.screen if self._compose_screen is None else self._compose_screen

@property
def workers(self) -> WorkerManager:
"""The [worker](/guide/workers/) manager.
Expand Down Expand Up @@ -3244,6 +3256,7 @@ async def take_screenshot() -> None:

async def _on_compose(self) -> None:
_rich_traceback_omit = True
self._compose_screen = self.screen
try:
widgets = [*self.screen._nodes, *compose(self)]
except TypeError as error:
Expand Down
2 changes: 1 addition & 1 deletion src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ class Content(Visual):

def __init__(
self,
text: str,
text: str = "",
spans: list[Span] | None = None,
cell_length: int | None = None,
) -> None:
Expand Down
46 changes: 32 additions & 14 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,17 @@ def __init__(

super().__init__()

def _get_dom_base(self) -> DOMNode:
"""Get the DOM base node (typically self).

All DOM queries on this node will use the return value as the root node.
This method allows the App to query the default screen, and not the active screen.

Returns:
DOMNode.
"""
return self

def set_reactive(
self, reactive: Reactive[ReactiveType], value: ReactiveType
) -> None:
Expand Down Expand Up @@ -1380,10 +1391,11 @@ def query(
from textual.css.query import DOMQuery, QueryType
from textual.widget import Widget

node = self._get_dom_base()
if isinstance(selector, str) or selector is None:
return DOMQuery[Widget](self, filter=selector)
return DOMQuery[Widget](node, filter=selector)
else:
return DOMQuery[QueryType](self, filter=selector.__name__)
return DOMQuery[QueryType](node, filter=selector.__name__)

if TYPE_CHECKING:

Expand Down Expand Up @@ -1411,10 +1423,11 @@ def query_children(
from textual.css.query import DOMQuery, QueryType
from textual.widget import Widget

node = self._get_dom_base()
if isinstance(selector, str) or selector is None:
return DOMQuery[Widget](self, deep=False, filter=selector)
return DOMQuery[Widget](node, deep=False, filter=selector)
else:
return DOMQuery[QueryType](self, deep=False, filter=selector.__name__)
return DOMQuery[QueryType](node, deep=False, filter=selector.__name__)

if TYPE_CHECKING:

Expand Down Expand Up @@ -1449,6 +1462,8 @@ def query_one(
"""
_rich_traceback_omit = True

base_node = self._get_dom_base()

if isinstance(selector, str):
query_selector = selector
else:
Expand All @@ -1462,20 +1477,20 @@ def query_one(
) from None

if all(selectors.is_simple for selectors in selector_set):
cache_key = (self._nodes._updates, query_selector, expect_type)
cached_result = self._query_one_cache.get(cache_key)
cache_key = (base_node._nodes._updates, query_selector, expect_type)
cached_result = base_node._query_one_cache.get(cache_key)
if cached_result is not None:
return cached_result
else:
cache_key = None

for node in walk_depth_first(self, with_root=False):
for node in walk_depth_first(base_node, with_root=False):
if not match(selector_set, node):
continue
if expect_type is not None and not isinstance(node, expect_type):
continue
if cache_key is not None:
self._query_one_cache[cache_key] = node
base_node._query_one_cache[cache_key] = node
return node

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

base_node = self._get_dom_base()

if isinstance(selector, str):
query_selector = selector
else:
Expand All @@ -1531,14 +1548,14 @@ def query_exactly_one(
) from None

if all(selectors.is_simple for selectors in selector_set):
cache_key = (self._nodes._updates, query_selector, expect_type)
cached_result = self._query_one_cache.get(cache_key)
cache_key = (base_node._nodes._updates, query_selector, expect_type)
cached_result = base_node._query_one_cache.get(cache_key)
if cached_result is not None:
return cached_result
else:
cache_key = None

children = walk_depth_first(self, with_root=False)
children = walk_depth_first(base_node, with_root=False)
iter_children = iter(children)
for node in iter_children:
if not match(selector_set, node):
Expand All @@ -1553,7 +1570,7 @@ def query_exactly_one(
"Call to query_one resulted in more than one matched node"
)
if cache_key is not None:
self._query_one_cache[cache_key] = node
base_node._query_one_cache[cache_key] = node
return node

raise NoMatches(f"No nodes match {selector!r} on {self!r}")
Expand Down Expand Up @@ -1589,6 +1606,7 @@ def query_ancestor(
Returns:
A DOMNode or subclass if `expect_type` is provided.
"""
base_node = self._get_dom_base()
if isinstance(selector, str):
query_selector = selector
else:
Expand All @@ -1600,8 +1618,8 @@ def query_ancestor(
raise InvalidQueryFormat(
f"Unable to parse {query_selector!r} as a query; check for syntax errors"
) from None
if self.parent is not None:
for node in self.parent.ancestors_with_self:
if base_node.parent is not None:
for node in base_node.parent.ancestors_with_self:
if not match(selector_set, node):
continue
if expect_type is not None and not isinstance(node, expect_type):
Expand Down
2 changes: 1 addition & 1 deletion src/textual/pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ async def _post_mouse_events(
elif isinstance(widget, Widget):
target_widget = widget
else:
target_widget = app.query_one(widget)
target_widget = app.screen.query_one(widget)

message_arguments = _get_mouse_message_arguments(
target_widget,
Expand Down
3 changes: 0 additions & 3 deletions src/textual/scrollbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,6 @@ class ScrollBarCorner(Widget):
"""Widget which fills the gap between horizontal and vertical scrollbars,
should they both be present."""

def __init__(self, name: str | None = None):
super().__init__(name=name)

def render(self) -> RenderableType:
assert self.parent is not None
styles = self.parent.styles
Expand Down
12 changes: 6 additions & 6 deletions tests/css/test_screen_css.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ def on_mount(self):


def check_colors_before_screen_css(app: BaseApp):
assert app.query_one("#app-css").styles.background == GREEN
assert app.query_one("#screen-css-path").styles.background == GREEN
assert app.query_one("#screen-css").styles.background == GREEN
assert app.screen.query_one("#app-css").styles.background == GREEN
assert app.screen.query_one("#screen-css-path").styles.background == GREEN
assert app.screen.query_one("#screen-css").styles.background == GREEN


def check_colors_after_screen_css(app: BaseApp):
assert app.query_one("#app-css").styles.background == GREEN
assert app.query_one("#screen-css-path").styles.background == BLUE
assert app.query_one("#screen-css").styles.background == RED
assert app.screen.query_one("#app-css").styles.background == GREEN
assert app.screen.query_one("#screen-css-path").styles.background == BLUE
assert app.screen.query_one("#screen-css").styles.background == RED


async def test_screen_pushing_and_popping_does_not_reparse_css():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def compose(self) -> ComposeResult:
yield Footer()

def on_mount(self) -> None:
self.query_one("Screen").scroll_end()
self.screen.scroll_end()


app = ScrollOffByOne()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def compose(self) -> ComposeResult:
yield Static("Hello", id="hello")

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

app = ScrollApp()
async with app.run_test() as pilot:
Expand Down
16 changes: 8 additions & 8 deletions tests/test_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def on_mount(self):

app = MyApp()
async with app.run_test():
assert app.query_one("HeaderTitle").text == "app title"
assert app.screen.query_one("HeaderTitle").text == "app title"


async def test_screen_title_overrides_app_title():
Expand All @@ -34,7 +34,7 @@ def on_mount(self):

app = MyApp()
async with app.run_test():
assert app.query_one("HeaderTitle").text == "screen title"
assert app.screen.query_one("HeaderTitle").text == "screen title"


async def test_screen_title_reactive_updates_title():
Expand All @@ -54,7 +54,7 @@ def on_mount(self):
async with app.run_test() as pilot:
app.screen.title = "new screen title"
await pilot.pause()
assert app.query_one("HeaderTitle").text == "new screen title"
assert app.screen.query_one("HeaderTitle").text == "new screen title"


async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set():
Expand All @@ -74,7 +74,7 @@ def on_mount(self):
async with app.run_test() as pilot:
app.title = "new app title"
await pilot.pause()
assert app.query_one("HeaderTitle").text == "screen title"
assert app.screen.query_one("HeaderTitle").text == "screen title"


async def test_screen_sub_title_none_is_ignored():
Expand All @@ -90,7 +90,7 @@ def on_mount(self):

app = MyApp()
async with app.run_test():
assert app.query_one("HeaderTitle").sub_text == "app sub-title"
assert app.screen.query_one("HeaderTitle").sub_text == "app sub-title"


async def test_screen_sub_title_overrides_app_sub_title():
Expand All @@ -108,7 +108,7 @@ def on_mount(self):

app = MyApp()
async with app.run_test():
assert app.query_one("HeaderTitle").sub_text == "screen sub-title"
assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title"


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


async def test_app_sub_title_reactive_does_not_update_sub_title_when_screen_sub_title_is_set():
Expand All @@ -148,4 +148,4 @@ def on_mount(self):
async with app.run_test() as pilot:
app.sub_title = "new app sub-title"
await pilot.pause()
assert app.query_one("HeaderTitle").sub_text == "screen sub-title"
assert app.screen.query_one("HeaderTitle").sub_text == "screen sub-title"
4 changes: 2 additions & 2 deletions tests/test_pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async def test_pilot_click_screen():
Check we can use `Screen` as a selector for a click."""

async with App().run_test() as pilot:
await pilot.click("Screen")
await pilot.click()


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

async with App().run_test() as pilot:
await pilot.hover("Screen")
await pilot.hover()


@pytest.mark.parametrize(
Expand Down
20 changes: 10 additions & 10 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@
from textual.widgets import Input, Label


def test_query_errors():
async def test_query_errors():
app = App()
async with app.run_test():
with pytest.raises(InvalidQueryFormat):
app.query_one("foo_bar")

with pytest.raises(InvalidQueryFormat):
app.query_one("foo_bar")

with pytest.raises(InvalidQueryFormat):
app.query("foo_bar")
with pytest.raises(InvalidQueryFormat):
app.query("foo_bar")

with pytest.raises(InvalidQueryFormat):
app.query("1")
with pytest.raises(InvalidQueryFormat):
app.query("1")

with pytest.raises(InvalidQueryFormat):
app.query_one("1")
with pytest.raises(InvalidQueryFormat):
app.query_one("1")


def test_query():
Expand Down
Loading
Loading