From 29f7adc5a1a2f47cf002861eeac4af7196e37e20 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Feb 2025 21:13:18 +0000 Subject: [PATCH 1/8] change to app query model --- src/textual/app.py | 7 +++ src/textual/content.py | 2 +- src/textual/dom.py | 43 +++++++++++++------ src/textual/pilot.py | 2 +- tests/css/test_screen_css.py | 12 +++--- .../snapshot_apps/dock_scroll_off_by_one.py | 2 +- tests/test_compositor.py | 2 +- tests/test_header.py | 16 +++---- tests/test_pilot.py | 4 +- tests/test_query.py | 20 ++++----- tests/test_screen_modes.py | 4 +- tests/test_screens.py | 16 ------- tests/test_widget.py | 2 +- tests/text_area/test_escape_binding.py | 2 +- 14 files changed, 70 insertions(+), 64 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index dfcbfd0c45..3cedcb58f2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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"}: @@ -833,6 +836,9 @@ def __init_subclass__(cls, *args, **kwargs) -> None: 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 @@ 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: diff --git a/src/textual/content.py b/src/textual/content.py index 397addff47..3719e8fbef 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -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: diff --git a/src/textual/dom.py b/src/textual/dom.py index 9587bca586..dcdefe125a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -235,6 +235,14 @@ def __init__( 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 @@ 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: @@ -1411,10 +1420,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: @@ -1449,6 +1459,8 @@ def query_one( """ _rich_traceback_omit = True + base_node = self._get_dom_base() + if isinstance(selector, str): query_selector = selector else: @@ -1462,20 +1474,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}") @@ -1518,6 +1530,8 @@ def query_exactly_one( """ _rich_traceback_omit = True + base_node = self._get_dom_base() + if isinstance(selector, str): query_selector = selector else: @@ -1531,14 +1545,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): @@ -1553,7 +1567,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}") @@ -1589,6 +1603,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: @@ -1600,8 +1615,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): diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 115c10cb70..a9ff1d2067 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -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, diff --git a/tests/css/test_screen_css.py b/tests/css/test_screen_css.py index 9e6668f6ac..a39e265cae 100644 --- a/tests/css/test_screen_css.py +++ b/tests/css/test_screen_css.py @@ -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(): diff --git a/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py b/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py index afbbf0966c..4dc52cb044 100644 --- a/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py +++ b/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py @@ -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() diff --git a/tests/test_compositor.py b/tests/test_compositor.py index e5dbcdbc6d..3717d07de5 100644 --- a/tests/test_compositor.py +++ b/tests/test_compositor.py @@ -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: diff --git a/tests/test_header.py b/tests/test_header.py index 45df30fa20..6ffdf0faad 100644 --- a/tests/test_header.py +++ b/tests/test_header.py @@ -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(): @@ -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(): @@ -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(): @@ -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(): @@ -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(): @@ -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(): @@ -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(): @@ -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" diff --git a/tests/test_pilot.py b/tests/test_pilot.py index a44e827a74..c212fccaae 100644 --- a/tests/test_pilot.py +++ b/tests/test_pilot.py @@ -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( diff --git a/tests/test_query.py b/tests/test_query.py index 1199f7d5f0..0c0759b653 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -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(): diff --git a/tests/test_screen_modes.py b/tests/test_screen_modes.py index 35576c3915..bf246a242b 100644 --- a/tests/test_screen_modes.py +++ b/tests/test_screen_modes.py @@ -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") diff --git a/tests/test_screens.py b/tests/test_screens.py index 774a358ed7..36bcd4cb5d 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -20,22 +20,6 @@ ) -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 = { diff --git a/tests/test_widget.py b/tests/test_widget.py index 1297aa18d1..adaa927b1a 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -341,7 +341,7 @@ def compose(self) -> ComposeResult: 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] = [] diff --git a/tests/text_area/test_escape_binding.py b/tests/text_area/test_escape_binding.py index 8d837e5c75..3a8c653e01 100644 --- a/tests/text_area/test_escape_binding.py +++ b/tests/text_area/test_escape_binding.py @@ -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) From dbe53a4116d38bd0ff4fcade3701b776b23babd6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Feb 2025 21:14:30 +0000 Subject: [PATCH 2/8] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d89105a52..734c753223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [3.1.0] - + +### Changed + +- `App.query` and friends will now always query the default (first) screen + ## [2.1.0] - 2025-02-19 ### Fixed From 72138945e2564ed12cf8550eec78933a674f0b7c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Feb 2025 21:19:50 +0000 Subject: [PATCH 3/8] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 734c753223..60b083101b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [3.1.0] - +## Unreleased ### Changed From 33dd0030f53db57fc4b7e854ddac53edc9eaccba Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Feb 2025 21:20:30 +0000 Subject: [PATCH 4/8] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b083101b..d3356af110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- `App.query` and friends will now always query the default (first) screen +- `App.query` and friends will now always query the default (first) screen, not necessarily the active screen. ## [2.1.0] - 2025-02-19 From 202f2168dbb783abe8935fc9ac1e9278defc2d42 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Feb 2025 21:23:34 +0000 Subject: [PATCH 5/8] default screen property --- src/textual/app.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 3cedcb58f2..0ff3371415 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -837,7 +837,7 @@ def __init_subclass__(cls, *args, **kwargs) -> None: 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 + return self.default_screen def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" @@ -847,6 +847,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. From cf2301d27a3d155c6e78bf0fa719881ece3b37cf Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Feb 2025 21:34:09 +0000 Subject: [PATCH 6/8] docstrings --- src/textual/app.py | 1 + src/textual/dom.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 0ff3371415..cb52ba2db0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -837,6 +837,7 @@ 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: diff --git a/src/textual/dom.py b/src/textual/dom.py index dcdefe125a..00b5595c65 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -238,6 +238,9 @@ def __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. """ From 0aaafec814ec308ebd7379090d69b15402239015 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Feb 2025 21:53:21 +0000 Subject: [PATCH 7/8] changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3356af110..5836edd361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- `App.query` and friends will now always query the default (first) screen, not necessarily the active screen. +- 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("")` ## [2.1.0] - 2025-02-19 From 158a015cfaa02f2b86297f9efc5761836fcc06ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 20 Feb 2025 20:43:00 +0000 Subject: [PATCH 8/8] superfluous code --- src/textual/scrollbar.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 7efccc6b96..b4daaf3a01 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -392,9 +392,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