From 9c7a48fc1389fb8c952fa68ff30988f55b3c55ab Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 9 Oct 2022 16:11:16 +0100 Subject: [PATCH] words --- docs/guide/queries.md | 135 ++++++++++++++++++++++++++++++++++----- docs/tutorial.md | 5 +- src/textual/app.py | 6 +- src/textual/css/query.py | 10 +-- src/textual/dom.py | 15 +++-- tests/test_dom.py | 6 +- 6 files changed, 144 insertions(+), 33 deletions(-) diff --git a/docs/guide/queries.md b/docs/guide/queries.md index 1a53a3d28..13579ae83 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -1,14 +1,38 @@ # DOM Queries -In the previous chapter we introduced the [DOM](../guide/CSS.md#the-dom), which represents the widgets in a Textual app. We saw how you can apply styles to the DOM with CSS *selectors*. +In the previous chapter we introduced the [DOM](../guide/CSS.md#the-dom) which is how Textual apps keep track of widgets. We saw how you can apply styles to the DOM with CSS [selectors](./CSS.md#selectors). -Selectors are a very useful thing and can do more that apply styles. We can also modify widgets using selectors in a simple expressive way. Let's look at how! +Selectors are a very useful idea and can do more that apply styles. We can also find widgets in Python code with selectors, and make updates to widgets in a simple expressive way. Let's look at how! + +## Query one + +The [query_one][textual.dom.DOMNode.query_one] method gets a single widget in an app or other widget. If you call it with a selector it will return the first matching widget. + +Let's say we have a widget with an ID of `send` and we want to get a reference to it in our app. We could do this with the following: + +```python +send_button = self.query_one("#send") +``` + +If there is no widget with an ID of `send`, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception. Otherwise it will return the matched widget. + +You can also add a second parameter for the expected type. + +```python +send_button = self.query_one("#send", Button) +``` + +If the matched widget is *not* a button (i.e. if `isinstance(widget, Button)` equals `False`), Textual will raise a [WrongType][textual.css.query.WrongType] exception. + +!!! tip + + The second parameter allows type-checkers like MyPy know the exact return type. Without it, MyPy would only know the result of `query_one` is a Widget (the base class). ## Making queries -Apps and widgets have a [query][textual.dom.DOMNode.query] method which finds (or queries) widgets. Calling this method will return a [DOMQuery][textual.css.query.DOMQuery] object which is a container (list-like) object with widgets you may iterate over. +Apps and widgets have a [query][textual.dom.DOMNode.query] method which finds (or queries) widgets. This method returns a [DOMQuery][textual.css.query.DOMQuery] object which is a list-like container of widgets. -If you call `query` with no arguments, you will get back a `DOMQuery` containing all widgets. This method is *recursive*, meaning it will return all child widgets. +If you call `query` with no arguments, you will get back a `DOMQuery` containing all widgets. This method is *recursive*, meaning it will also return child widgets (as many levels as required). Here's how you might iterate over all the widgets in your app: @@ -17,11 +41,15 @@ for widget in self.query(): print(widget) ``` -Called on the `app`, this will retrieve all widgets in the app. If you call the same method on a widget, it will return children of that widget. +Called on the `app`, this will retrieve all widgets in the app. If you call the same method on a widget, it will return the children of that widget. + +!!! note + + All the query and related methods work on both App and Widget sub-classes. ### Query selectors -You can also call `query` with a CSS selector. Let's look a few examples: +You can call `query` with a CSS selector. Let's look a few examples: If we want to find all the button widgets, we could do something like the following: @@ -30,32 +58,55 @@ for button in self.query("Button"): print(button) ``` -Any selector that works in CSS will work. For instance, if we want to find all the disabled buttons in a Dialog widget, we could do something like the following: +Any selector that works in CSS will work with the `query` method. For instance, if we want to find all the disabled buttons in a Dialog widget, we could do this: ```python -for button in self.query("Dialog > Button.disabled"): +for button in self.query("Dialog Button.disabled"): print(button) ``` -### First and Last +!!! info -The [first][textual.css.query.DOMQuery.first] and [last][textual.css.query.DOMQuery.last] methods will return the first and last widgets from the selector, respectively. + The selector `Dialog Button.disabled` says find all the `Button` with a CSS class of `disabled` that are a child of a `Dialog` widget. -Here's how we might find the last button in an app. +### Results + +Query objects have a [results][textual.css.query.DOMQuery.results] method which is an alternative way of iterating over widgets. If you supply a type (i.e. a Widget class) then this method will generate only objects of that type. + +The following example queries for widgets with the `disabled` CSS class and iterates over just the Button objects. + +```python +for button in self.query(".disabled").results(Button): + print(button) +``` + +!!! tip + + This method allows type-checkers like MyPy to know the exact type of the object in the loop. Without it, MyPy would only know that `button` is a `Widget` (the base class). + +## Query objects + +We've seen that the [query][textual.dom.DOMNode.query] method returns a [DOMQuery][textual.css.query.DOMQuery] object you can iterate over in a for loop. Query objects behave like Python lists and support all of the same operations (such as `query[0]`, `len(query)` ,`reverse(query)` etc). They also have a number of other methods to simplify retrieving and modifying widgets. + +## First and last + +The [first][textual.css.query.DOMQuery.first] and [last][textual.css.query.DOMQuery.last] methods return the first or last matching widget from the selector, respectively. + +Here's how we might find the _last_ button in an app: ```python last_button = self.query("Button").last() ``` -If there are no buttons, textual will raise a [NoMatchingNodesError][textual.css.query.NoMatchingNodesError] exception. Otherwise it will return a button widgets. +If there are no buttons, Textual will raise a [NoMatches][textual.css.query.NoMatches] exception. Otherwise it will return a button widget. -Both `first()` and `last()` accept an `expect_type` argument that should be the class of the widget you are expecting. For instance, lets say we want to get the last with class `.disabled`, and we want to check it really is a button. We could do this: +Both `first()` and `last()` accept an `expect_type` argument that should be the class of the widget you are expecting. Let's say we want to get the last widget with class `.disabled`, and we want to check it really is a button. We could do this: ```python -disabled_button = self.query(".disables").last(Button) +disabled_button = self.query(".disabled").last(Button) ``` -The query selects all widgets with a `disabled` CSS class. The `last` method ensures that it is a `Button` and not any other kind of widget. +The query selects all widgets with a `disabled` CSS class. The `last` method gets the last disabled widget and checks it is a `Button` and not any other kind of widget. If the last widget is *not* a button, Textual will raise a [WrongType][textual.css.query.WrongType] exception. @@ -63,5 +114,57 @@ If the last widget is *not* a button, Textual will raise a [WrongType][textual.c Specifying the expected type allows type-checkers like MyPy to know the exact return type. -### Filtering +## Filter + +Query objects have a [filter][textual.css.query.DOMQuery.filter] method which further refines a query. This method will return a new query object which widgets that match both the original query _and_ the new selector + +Let's say we have a query which gets all the buttons in an app, and we want a new query object with just the disabled buttons. We could write something like this: + +```python +# Get all the Buttons +buttons_query = self.query("Button") +# Buttons with 'disabled' CSS class +disabled_buttons = buttons_query.filter(".disabled") +``` + +Iterating over `disabled_buttons` will give us all the disabled buttons. + +## Exclude + +Query objects have a [exclude][textual.css.query.DOMQuery.exclude] method which is the logical opposite of [filter][textual.css.query.DOMQuery.filter]. The `exclude` method removes any widgets from the query object which match a selector. + +Here's how we could get all the buttons which *don't* have the `disabled` class set. + +```python +# Get all the Buttons +buttons_query = self.query("Button") +# Remove all the Buttons with the 'disabled' CSS class +enabled_buttons = buttons_query.exclude(".disabled") +``` + +## Loop-free operations + +Once you have a query object, you can loop over it to call methods on the matched widgets. Query objects also support a number of methods which make an update to every matched widget without an explicit loop. + +For instance, let's say we want to disable all buttons in an app. We could do this by calling [add_class()][textual.css.query.DOMQuery.add_class] on a query object. + +```python +self.query("Button").add_class("disabled") +``` + +This single line is equivalent to the following: + +```python +for widget in self.query("Button"): + widget.add_class("disabled") +``` + +Here are the other loop-free methods on query objects: + +- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. +- [add_class][textual.css.query.DOMQuery.add_class] Adds a CSS class (or classes) to matched widgets. +- [remove_class][textual.css.query.DOMQuery.remove_class] Removes a CSS class (or classes) from matched widgets. +- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. +- [remove][textual.css.query.DOMQuery.remove] Removes matched widgets from the DOM. +- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. diff --git a/docs/tutorial.md b/docs/tutorial.md index 9dba6a822..c9e03c89e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -199,7 +199,10 @@ While it's possible to set all styles for an app this way, it is rarely necessar !!! info - The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn! + The dialect of CSS used in Textual is greatly simplified over web based CSS and much easier to learn. + + +CSS makes it easy to iterate on the design of your app and enables [live-editing](./guide/devtools.md#live-editing) — you can edit CSS and see the changes without restarting the app! Let's add a CSS file to our application. diff --git a/src/textual/app.py b/src/textual/app.py index 7cdb9a697..8b452dc56 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -37,7 +37,7 @@ from ._context import active_app from ._event_broker import NoHandler, extract_handler_actions from ._filter import LineFilter, Monochrome from .binding import Bindings, NoBinding -from .css.query import NoMatchingNodesError +from .css.query import NoMatches from .css.stylesheet import Stylesheet from .design import ColorSystem from .devtools.client import DevtoolsClient, DevtoolsConnectionError, DevtoolsLog @@ -774,7 +774,7 @@ class App(Generic[ReturnType], DOMNode): DOMNode: The first child of this node with the specified ID. Raises: - NoMatchingNodesError: if no children could be found for this ID + NoMatches: if no children could be found for this ID """ return self.screen.get_child(id) @@ -1553,7 +1553,7 @@ class App(Generic[ReturnType], DOMNode): """ try: node = self.query(f"#{widget_id}").first() - except NoMatchingNodesError: + except NoMatches: pass else: if isinstance(node, Widget): diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 5779c0bc9..93addb6fb 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -34,7 +34,7 @@ class QueryError(Exception): """Base class for a query related error.""" -class NoMatchingNodesError(QueryError): +class NoMatches(QueryError): """No nodes matched the query.""" @@ -180,7 +180,7 @@ class DOMQuery(Generic[QueryType]): Raises: WrongType: If the wrong type was found. - NoMatchingNodesError: If there are no matching nodes in the query. + NoMatches: If there are no matching nodes in the query. Returns: Widget | ExpectType: The matching Widget. @@ -194,7 +194,7 @@ class DOMQuery(Generic[QueryType]): ) return first else: - raise NoMatchingNodesError(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r}") @overload def last(self) -> Widget: @@ -215,7 +215,7 @@ class DOMQuery(Generic[QueryType]): Raises: WrongType: If the wrong type was found. - NoMatchingNodesError: If there are no matching nodes in the query. + NoMatches: If there are no matching nodes in the query. Returns: Widget | ExpectType: The matching Widget. @@ -229,7 +229,7 @@ class DOMQuery(Generic[QueryType]): ) return last else: - raise NoMatchingNodesError(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r}") @overload def results(self) -> Iterator[Widget]: diff --git a/src/textual/dom.py b/src/textual/dom.py index d102da4ad..8bb706d1c 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -30,7 +30,7 @@ from .css.errors import StyleValueError, DeclarationError from .css.parse import parse_declarations from .css.styles import Styles, RenderStyles from .css.tokenize import IDENTIFIER -from .css.query import NoMatchingNodesError +from .css.query import NoMatches from .message_pump import MessagePump from .timer import Timer @@ -405,6 +405,11 @@ class DOMNode(MessagePump): @property def visible(self) -> bool: + """Check if the node is visible or None. + + Returns: + bool: True if the node is visible. + """ return self.styles.visibility != "hidden" @visible.setter @@ -669,12 +674,12 @@ class DOMNode(MessagePump): DOMNode: The first child of this node with the ID. Raises: - NoMatchingNodesError: if no children could be found for this ID + NoMatches: if no children could be found for this ID """ for child in self.children: if child.id == id: return child - raise NoMatchingNodesError(f"No child found with id={id!r}") + raise NoMatches(f"No child found with id={id!r}") ExpectType = TypeVar("ExpectType", bound="Widget") @@ -725,8 +730,8 @@ class DOMNode(MessagePump): """Get the first Widget matching the given selector or selector type. Args: - selector (str | type, optional): A selector. - expect_type (type, optional): Require the object be of the supplied type, or None for any type. + selector (str | type): A selector. + expect_type (type | None, optional): Require the object be of the supplied type, or None for any type. Defaults to None. Returns: diff --git a/tests/test_dom.py b/tests/test_dom.py index a7ec46482..e4254f6e5 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -1,7 +1,7 @@ import pytest from textual.css.errors import StyleValueError -from textual.css.query import NoMatchingNodesError +from textual.css.query import NoMatches from textual.dom import DOMNode, BadIdentifier @@ -48,12 +48,12 @@ def test_get_child_gets_first_child(parent): def test_get_child_no_matching_child(parent): - with pytest.raises(NoMatchingNodesError): + with pytest.raises(NoMatches): parent.get_child(id="doesnt-exist") def test_get_child_only_immediate_descendents(parent): - with pytest.raises(NoMatchingNodesError): + with pytest.raises(NoMatches): parent.get_child(id="grandchild1")