Skip to content

Commit caa43f0

Browse files
committed
Optimize query_one
1 parent 8178119 commit caa43f0

File tree

3 files changed

+76
-5
lines changed

3 files changed

+76
-5
lines changed

src/textual/css/parse.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import dataclasses
4+
import re
45
from functools import lru_cache
56
from typing import Iterable, Iterator, NoReturn
67

@@ -16,7 +17,13 @@
1617
SelectorType,
1718
)
1819
from textual.css.styles import Styles
19-
from textual.css.tokenize import Token, tokenize, tokenize_declarations, tokenize_values
20+
from textual.css.tokenize import (
21+
IDENTIFIER,
22+
Token,
23+
tokenize,
24+
tokenize_declarations,
25+
tokenize_values,
26+
)
2027
from textual.css.tokenizer import ReferencedBy, UnexpectedEnd
2128
from textual.css.types import CSSLocation, Specificity3
2229
from textual.suggestions import get_suggestion
@@ -33,6 +40,20 @@
3340
"nested": (SelectorType.NESTED, (0, 0, 0)),
3441
}
3542

43+
RE_IDENTIFIER = re.compile(r"\#" + IDENTIFIER)
44+
45+
46+
def is_id_selector(selector: str) -> bool:
47+
"""Is the selector an ID selector, i.e. "#foo"?
48+
49+
Args:
50+
selector: A CSS selector.
51+
52+
Returns:
53+
`True` if the selector is a simple ID selector, otherwise `False`.
54+
"""
55+
return RE_IDENTIFIER.fullmatch(selector) is not None
56+
3657

3758
def _add_specificity(
3859
specificity1: Specificity3, specificity2: Specificity3

src/textual/dom.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from textual.css.constants import VALID_DISPLAY, VALID_VISIBILITY
4141
from textual.css.errors import DeclarationError, StyleValueError
4242
from textual.css.match import match
43-
from textual.css.parse import parse_declarations, parse_selectors
43+
from textual.css.parse import is_id_selector, parse_declarations, parse_selectors
4444
from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType
4545
from textual.css.styles import RenderStyles, Styles
4646
from textual.css.tokenize import IDENTIFIER
@@ -49,7 +49,7 @@
4949
from textual.reactive import Reactive, ReactiveError, _Mutated, _watch
5050
from textual.style import Style as VisualStyle
5151
from textual.timer import Timer
52-
from textual.walk import walk_breadth_first, walk_depth_first
52+
from textual.walk import walk_breadth_first, walk_breadth_search_id, walk_depth_first
5353
from textual.worker_manager import WorkerManager
5454

5555
if TYPE_CHECKING:
@@ -1466,6 +1466,26 @@ def query_one(
14661466
else:
14671467
query_selector = selector.__name__
14681468

1469+
if is_id_selector(query_selector):
1470+
cache_key = (base_node._nodes._updates, query_selector, expect_type)
1471+
cached_result = base_node._query_one_cache.get(cache_key)
1472+
if cached_result is not None:
1473+
return cached_result
1474+
if (
1475+
node := walk_breadth_search_id(
1476+
base_node, query_selector[1:], with_root=False
1477+
)
1478+
) is not None:
1479+
if expect_type is not None and not isinstance(node, expect_type):
1480+
raise WrongType(
1481+
f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}"
1482+
)
1483+
base_node._query_one_cache[cache_key] = node
1484+
return node
1485+
raise NoMatches(
1486+
f"No nodes match {query_selector!r} on {base_node!r} {list(base_node._nodes)}"
1487+
)
1488+
14691489
try:
14701490
selector_set = parse_selectors(query_selector)
14711491
except TokenError:
@@ -1492,7 +1512,7 @@ def query_one(
14921512
base_node._query_one_cache[cache_key] = node
14931513
return node
14941514

1495-
raise NoMatches(f"No nodes match {selector!r} on {base_node!r}")
1515+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
14961516

14971517
if TYPE_CHECKING:
14981518

@@ -1572,7 +1592,7 @@ def query_exactly_one(
15721592
base_node._query_one_cache[cache_key] = node
15731593
return node
15741594

1575-
raise NoMatches(f"No nodes match {selector!r} on {base_node!r}")
1595+
raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}")
15761596

15771597
if TYPE_CHECKING:
15781598

src/textual/walk.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,33 @@ def walk_breadth_first(
137137
if isinstance(node, check_type):
138138
yield node
139139
extend(node._nodes)
140+
141+
142+
def walk_breadth_search_id(
143+
root: DOMNode, node_id: str, *, with_root: bool = True
144+
) -> DOMNode | None:
145+
"""Special case to walk breadth first searching for a node with a given id.
146+
147+
This is more efficient that [walk_breadth_first][textual.walk.walk_breadth_first] for this special case, as it can use an index.
148+
149+
Args:
150+
root: The root node (starting point).
151+
node_id: Node id to search for.
152+
with_root: Consider the root node? If the root has the node id, then return it.
153+
154+
Returns:
155+
A DOMNode if a node was found, otherwise `None`.
156+
"""
157+
158+
if with_root and root.id == node_id:
159+
return root
160+
161+
queue: deque[DOMNode] = deque()
162+
queue.append(root)
163+
164+
while queue:
165+
node = queue.popleft()
166+
if (found_node := node._nodes._get_by_id(node_id)) is not None:
167+
return found_node
168+
queue.extend(node._nodes)
169+
return None

0 commit comments

Comments
 (0)