change to app query model

This commit is contained in:
Will McGugan
2025-02-19 21:13:18 +00:00
parent 7c25ab5bc4
commit 29f7adc5a1
14 changed files with 70 additions and 64 deletions

View File

@@ -798,6 +798,9 @@ class App(Generic[ReturnType], DOMNode):
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"}:
@@ -833,6 +836,9 @@ class App(Generic[ReturnType], DOMNode):
return super().__init_subclass__(*args, **kwargs)
def _get_dom_base(self) -> DOMNode:
return self.screen if self._compose_screen is None else self._compose_screen
def validate_title(self, title: Any) -> str:
"""Make sure the title is set to a string."""
return str(title)
@@ -3237,6 +3243,7 @@ class App(Generic[ReturnType], DOMNode):
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:

View File

@@ -119,7 +119,7 @@ class Content(Visual):
def __init__(
self,
text: str,
text: str = "",
spans: list[Span] | None = None,
cell_length: int | None = None,
) -> None:

View File

@@ -235,6 +235,14 @@ class DOMNode(MessagePump):
super().__init__()
def _get_dom_base(self) -> DOMNode:
"""Get the DOM base node (typically self).
Returns:
DOMNode.
"""
return self
def set_reactive(
self, reactive: Reactive[ReactiveType], value: ReactiveType
) -> None:
@@ -1380,10 +1388,11 @@ class DOMNode(MessagePump):
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:
@@ -1411,10 +1420,11 @@ class DOMNode(MessagePump):
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:
@@ -1449,6 +1459,8 @@ class DOMNode(MessagePump):
"""
_rich_traceback_omit = True
base_node = self._get_dom_base()
if isinstance(selector, str):
query_selector = selector
else:
@@ -1462,20 +1474,20 @@ class DOMNode(MessagePump):
) 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}")
@@ -1518,6 +1530,8 @@ class DOMNode(MessagePump):
"""
_rich_traceback_omit = True
base_node = self._get_dom_base()
if isinstance(selector, str):
query_selector = selector
else:
@@ -1531,14 +1545,14 @@ class DOMNode(MessagePump):
) 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):
@@ -1553,7 +1567,7 @@ class DOMNode(MessagePump):
"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}")
@@ -1589,6 +1603,7 @@ class DOMNode(MessagePump):
Returns:
A DOMNode or subclass if `expect_type` is provided.
"""
base_node = self._get_dom_base()
if isinstance(selector, str):
query_selector = selector
else:
@@ -1600,8 +1615,8 @@ class DOMNode(MessagePump):
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):

View File

@@ -414,7 +414,7 @@ class Pilot(Generic[ReturnType]):
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,

View File

@@ -58,15 +58,15 @@ class SwitchBaseApp(BaseApp):
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():

View File

@@ -11,7 +11,7 @@ class ScrollOffByOne(App):
yield Footer()
def on_mount(self) -> None:
self.query_one("Screen").scroll_end()
self.screen.scroll_end()
app = ScrollOffByOne()

View File

@@ -30,7 +30,7 @@ async def test_compositor_scroll_placements():
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:

View File

@@ -16,7 +16,7 @@ async def test_screen_title_none_is_ignored():
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():
@@ -34,7 +34,7 @@ async def test_screen_title_overrides_app_title():
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():
@@ -54,7 +54,7 @@ async def test_screen_title_reactive_updates_title():
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():
@@ -74,7 +74,7 @@ async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set
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():
@@ -90,7 +90,7 @@ async def test_screen_sub_title_none_is_ignored():
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():
@@ -108,7 +108,7 @@ async def test_screen_sub_title_overrides_app_sub_title():
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():
@@ -128,7 +128,7 @@ async def test_screen_sub_title_reactive_updates_sub_title():
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():
@@ -148,4 +148,4 @@ async def test_app_sub_title_reactive_does_not_update_sub_title_when_screen_sub_
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"

View File

@@ -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():
@@ -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(

View File

@@ -14,20 +14,20 @@ from textual.widget import Widget
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():

View File

@@ -152,7 +152,7 @@ async def test_screen_stack_preserved(ModesApp: Type[App]):
# Build the stack up.
for _ in range(N):
await pilot.press("p")
fruits.append(str(app.query_one(Label).renderable))
fruits.append(str(app.screen.query_one(Label).renderable))
assert len(app.screen_stack) == N + 1
@@ -164,7 +164,7 @@ async def test_screen_stack_preserved(ModesApp: Type[App]):
# Check the stack.
assert len(app.screen_stack) == N + 1
for _ in range(N):
assert str(app.query_one(Label).renderable) == fruits.pop()
assert str(app.screen.query_one(Label).renderable) == fruits.pop()
await pilot.press("o")

View File

@@ -20,22 +20,6 @@ skip_py310 = pytest.mark.skipif(
)
async def test_screen_walk_children():
"""Test query only reports active screen."""
class ScreensApp(App):
pass
app = ScreensApp()
async with app.run_test() as pilot:
screen1 = Screen()
screen2 = Screen()
pilot.app.push_screen(screen1)
assert list(pilot.app.query("*")) == [screen1]
pilot.app.push_screen(screen2)
assert list(pilot.app.query("*")) == [screen2]
async def test_installed_screens():
class ScreensApp(App):
SCREENS = {

View File

@@ -341,7 +341,7 @@ async def test_compose_order() -> None:
class SelectBugApp(App[None]):
async def on_mount(self):
await self.push_screen(MyScreen(id="my-screen"))
self.query_one(Select)
self.screen.query_one(Select)
app = SelectBugApp()
messages: list[Message] = []

View File

@@ -48,7 +48,7 @@ async def test_escape_key_when_tab_behavior_is_indent():
assert isinstance(pilot.app.screen, TextAreaDialog)
assert isinstance(pilot.app.focused, TextArea)
pilot.app.query_one(TextArea).tab_behavior = "indent"
pilot.app.screen.query_one(TextArea).tab_behavior = "indent"
# Pressing escape should focus the button, not dismiss the dialog screen
await pilot.press("escape")
assert isinstance(pilot.app.screen, TextAreaDialog)