diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f429fa230..00acfeaa27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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/). +## Unreleased + +### Added + +- Added textual.getters https://github.com/Textualize/textual/pull/5930 + +### Changed + +- Potential breaking change: Changed default `query_one` and `query_exactly_one` search to breadth first https://github.com/Textualize/textual/pull/5930 + ## [3.6.0] - 2025-07-06 ### Fixed diff --git a/docs/api/getters.md b/docs/api/getters.md new file mode 100644 index 0000000000..45a042d3a3 --- /dev/null +++ b/docs/api/getters.md @@ -0,0 +1,5 @@ +--- +title: "textual.getters" +--- + +::: textual.getters diff --git a/examples/dictionary.py b/examples/dictionary.py index 56f4a17689..f68df022cd 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -6,7 +6,7 @@ raise ImportError("Please install httpx with 'pip install httpx' ") -from textual import work +from textual import getters, work from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.widgets import Input, Markdown @@ -17,6 +17,9 @@ class DictionaryApp(App): CSS_PATH = "dictionary.tcss" + results = getters.query_one("#results", Markdown) + input = getters.query_one(Input) + def compose(self) -> ComposeResult: yield Input(placeholder="Search for a word", id="dictionary-search") with VerticalScroll(id="results-container"): @@ -28,7 +31,7 @@ async def on_input_changed(self, message: Input.Changed) -> None: self.lookup_word(message.value) else: # Clear the results - await self.query_one("#results", Markdown).update("") + await self.results.update("") @work(exclusive=True) async def lookup_word(self, word: str) -> None: @@ -40,12 +43,12 @@ async def lookup_word(self, word: str) -> None: try: results = response.json() except Exception: - self.query_one("#results", Markdown).update(response.text) + self.results.update(response.text) return - if word == self.query_one(Input).value: + if word == self.input.value: markdown = self.make_word_markdown(results) - self.query_one("#results", Markdown).update(markdown) + self.results.update(markdown) def make_word_markdown(self, results: object) -> str: """Convert the results into markdown.""" diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index a2ef39e727..3be43d7068 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -202,6 +202,7 @@ nav: - "api/filter.md" - "api/fuzzy_matcher.md" - "api/geometry.md" + - "api/getters.md" - "api/layout.md" - "api/lazy.md" - "api/logger.md" diff --git a/src/textual/dom.py b/src/textual/dom.py index f07645da87..c91f6cfc0b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1484,7 +1484,7 @@ def query_one( else: cache_key = None - for node in walk_depth_first(base_node, with_root=False): + for node in walk_breadth_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): @@ -1555,7 +1555,7 @@ def query_exactly_one( else: cache_key = None - children = walk_depth_first(base_node, with_root=False) + children = walk_breadth_first(base_node, with_root=False) iter_children = iter(children) for node in iter_children: if not match(selector_set, node): diff --git a/src/textual/getters.py b/src/textual/getters.py new file mode 100644 index 0000000000..3cae9b8aa1 --- /dev/null +++ b/src/textual/getters.py @@ -0,0 +1,177 @@ +""" +Descriptors to define properties on your widget, screen, or App. + +""" + +from __future__ import annotations + +from typing import Generic, overload + +from textual.css.query import NoMatches, QueryType, WrongType +from textual.dom import DOMNode +from textual.widget import Widget + + +class query_one(Generic[QueryType]): + """Create a query one property. + + A query one property calls [Widget.query_one][textual.dom.DOMNode.query_one] when accessed, and returns + a widget. If the widget doesn't exist, then the property will raise the same exceptions as `Widget.query_one`. + + + Example: + ```python + from textual import getters + + class MyScreen(screen): + + # Note this is at the class level + output_log = getters.query_one("#output", RichLog) + + def compose(self) -> ComposeResult: + with containers.Vertical(): + yield RichLog(id="output") + + def on_mount(self) -> None: + self.output_log.write("Screen started") + # Equivalent to the following line: + # self.query_one("#output", RichLog).write("Screen started") + ``` + + + """ + + selector: str + expect_type: type[Widget] + + @overload + def __init__(self, selector: str) -> None: + self.selector = selector + self.expect_type = Widget + + @overload + def __init__(self, selector: type[QueryType]) -> None: + self.selector = selector.__name__ + self.expect_type = selector + + @overload + def __init__(self, selector: str, expect_type: type[QueryType]) -> None: + self.selector = selector + self.expect_type = expect_type + + @overload + def __init__(self, selector: type[QueryType], expect_type: type[QueryType]) -> None: + self.selector = selector.__name__ + self.expect_type = expect_type + + def __init__( + self, + selector: str | type[QueryType], + expect_type: type[QueryType] | None = None, + ) -> None: + if expect_type is None: + self.expect_type = Widget + else: + self.expect_type = expect_type + if isinstance(selector, str): + self.selector = selector + else: + self.selector = selector.__name__ + self.expect_type = selector + + @overload + def __get__( + self: "query_one[QueryType]", obj: DOMNode, obj_type: type[DOMNode] + ) -> QueryType: ... + + @overload + def __get__( + self: "query_one[QueryType]", obj: None, obj_type: type[DOMNode] + ) -> "query_one[QueryType]": ... + + def __get__( + self: "query_one[QueryType]", obj: DOMNode | None, obj_type: type[DOMNode] + ) -> QueryType | Widget | "query_one": + """Get the widget matching the selector and/or type.""" + if obj is None: + return self + query_node = obj.query_one(self.selector, self.expect_type) + return query_node + + +class child_by_id(Generic[QueryType]): + """Create a child_by_id property, which returns the child with the given ID. + + This is similar using [query_one][textual.getters.query_one] with an id selector, except that + only the immediate children are considered. It is also more efficient, as it doesn't need to search the DOM. + + + Example: + ```python + from textual import getters + + class MyScreen(screen): + + # Note this is at the class level + output_log = getters.child_by_id("#output", RichLog) + + def compose(self) -> ComposeResult: + yield RichLog(id="output") + + def on_mount(self) -> None: + self.output_log.write("Screen started") + ``` + + + """ + + child_id: str + expect_type: type[Widget] + + @overload + def __init__(self, child_id: str) -> None: + self.child_id = child_id + self.expect_type = Widget + + @overload + def __init__(self, child_id: str, expect_type: type[QueryType]) -> None: + self.child_id = child_id + self.expect_type = expect_type + + def __init__( + self, + child_id: str, + expect_type: type[QueryType] | None = None, + ) -> None: + if expect_type is None: + self.expect_type = Widget + else: + self.expect_type = expect_type + self.child_id = child_id + + @overload + def __get__( + self: "child_by_id[QueryType]", obj: DOMNode, obj_type: type[DOMNode] + ) -> QueryType: ... + + @overload + def __get__( + self: "child_by_id[QueryType]", obj: None, obj_type: type[DOMNode] + ) -> "child_by_id[QueryType]": ... + + def __get__( + self: "child_by_id[QueryType]", obj: DOMNode | None, obj_type: type[DOMNode] + ) -> QueryType | Widget | "child_by_id": + """Get the widget matching the selector and/or type.""" + if obj is None: + return self + child = obj._nodes._get_by_id(self.child_id) + if child is None: + raise NoMatches(f"No child found with id={id!r}") + if not isinstance(child, self.expect_type): + if not isinstance(child, self.expect_type): + raise WrongType( + f"Child with id={id!r} is wrong type; expected {self.expect_type}, got" + f" {type(child)}" + ) + return child diff --git a/tests/test_getters.py b/tests/test_getters.py new file mode 100644 index 0000000000..4ccfbf85e1 --- /dev/null +++ b/tests/test_getters.py @@ -0,0 +1,45 @@ +import pytest + +from textual import containers, getters +from textual.app import App, ComposeResult +from textual.css.query import NoMatches, WrongType +from textual.widget import Widget +from textual.widgets import Input, Label + + +async def get_getters() -> None: + """Check the getter descriptors work, and return expected errors.""" + + class QueryApp(App): + label1 = getters.query_one("#label1", Label) + label2 = getters.child_by_id("label2", Label) + label1_broken = getters.query_one("#label1", Input) + label2_broken = getters.child_by_id("label2", Input) + label1_missing = getters.query_one("#foo", Widget) + label2_missing = getters.child_by_id("bar", Widget) + + def compose(self) -> ComposeResult: + with containers.Vertical(): + yield Label(id="label1", classes=".red") + yield Label(id="label2", classes=".green") + + app = QueryApp() + async with app.run_test(): + + assert isinstance(app.label1, Label) + assert app.label1.id == "label1" + + assert isinstance(app.label2, Label) + assert app.label2.id == "label2" + + with pytest.raises(WrongType): + app.label1_broken + + with pytest.raises(WrongType): + app.label2_broken + + with pytest.raises(NoMatches): + app.label1_missing + + with pytest.raises(NoMatches): + app.label2_missing