This commit is contained in:
Will McGugan
2022-10-09 16:11:16 +01:00
parent d4db0ea4e0
commit 9c7a48fc13
6 changed files with 144 additions and 33 deletions

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -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]:

View File

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

View File

@@ -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")