DOM query

This commit is contained in:
Will McGugan
2021-11-01 20:40:15 +00:00
parent eff879b211
commit 945d75d91f
5 changed files with 167 additions and 93 deletions

66
src/textual/css/match.py Normal file
View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING
from .model import CombinatorType, Selector, SelectorSet, SelectorType
if TYPE_CHECKING:
from ..dom import DOMNode
def match(selector_sets: Iterable[SelectorSet], node: DOMNode):
for selector_set in selector_sets:
if _check_selectors(selector_set.selectors, node):
return True
return False
def _check_selectors(selectors: list[Selector], node: DOMNode) -> bool:
SAME = CombinatorType.SAME
DESCENDENT = CombinatorType.DESCENDENT
CHILD = CombinatorType.CHILD
css_path = node.css_path
path_count = len(css_path)
selector_count = len(selectors)
stack: list[tuple[int, int]] = [(0, 0)]
push = stack.append
pop = stack.pop
selector_index = 0
while stack:
selector_index, node_index = stack[-1]
if selector_index == selector_count:
return node_index + 1 == path_count
if node_index >= path_count:
pop()
continue
selector = selectors[selector_index]
path_node = css_path[node_index]
combinator = selector.combinator
stack[-1] = (selector_index, node_index)
if combinator == SAME:
# Check current node again
if selector.check(path_node):
stack[-1] = (selector_index + 1, node_index + selector.advance)
else:
pop()
elif combinator == DESCENDENT:
# Find a matching descendent
if selector.check(path_node):
pop()
push((selector_index + 1, node_index + selector.advance + 1))
push((selector_index + 1, node_index + selector.advance))
else:
stack[-1] = (selector_index, node_index + selector.advance + 1)
elif combinator == CHILD:
# Match the next node
if selector.check(path_node):
stack[-1] = (selector_index + 1, node_index + selector.advance)
else:
pop()
return False

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from rich import print
from functools import lru_cache
from typing import Iterator, Iterable
from .tokenize import tokenize, Token
@@ -30,7 +31,8 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = {
}
def parse_selectors(css_selectors: str) -> list[SelectorSet]:
@lru_cache(maxsize=1024)
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
tokens = iter(tokenize(css_selectors))
@@ -42,7 +44,6 @@ def parse_selectors(css_selectors: str) -> list[SelectorSet]:
while True:
try:
token = next(tokens)
print(token)
except EOFError:
break
if token.name == "pseudo_class":
@@ -74,7 +75,7 @@ def parse_selectors(css_selectors: str) -> list[SelectorSet]:
if selectors:
rule_selectors.append(selectors[:])
selector_set = list(SelectorSet.from_selectors(rule_selectors))
selector_set = tuple(SelectorSet.from_selectors(rule_selectors))
return selector_set

47
src/textual/css/query.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
import rich.repr
from typing import Iterable, Iterator, TYPE_CHECKING
from .match import match
from .parse import parse_selectors
if TYPE_CHECKING:
from ..dom import DOMNode
@rich.repr.auto(angular=True)
class DOMQuery:
def __init__(
self,
node: DOMNode | None = None,
selector: str | None = None,
nodes: Iterable[DOMNode] | None = None,
) -> None:
self._nodes: list[DOMNode]
if nodes is not None:
self._nodes = list(nodes)
elif node is not None:
self._nodes = list(node.walk_children())
else:
self._nodes = []
if selector is not None:
selector_set = parse_selectors(selector)
self._nodes = [_node for _node in self._nodes if match(selector_set, _node)]
def __iter__(self) -> Iterator[DOMNode]:
return iter(self._nodes)
def __rich_repr__(self) -> rich.repr.Result:
yield self._nodes
def filter(self, selector: str) -> DOMQuery:
selector_set = parse_selectors(selector)
query = DOMQuery()
query._nodes = [_node for _node in self._nodes if match(selector_set, _node)]
return query

View File

@@ -1,13 +1,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import rich.repr
from ..dom import DOMNode
from .errors import StylesheetError
from .match import _check_selectors
from .model import CombinatorType, RuleSet, Selector
from .parse import parse
from .styles import Styles
from .types import Specificity3
from ..dom import DOMNode
@rich.repr.auto
@@ -49,89 +53,9 @@ class Stylesheet:
def apply_rule(self, rule: RuleSet, node: DOMNode) -> None:
for selector_set in rule.selector_set:
if self.check_selectors(selector_set.selectors, node):
if _check_selectors(selector_set.selectors, node):
print(selector_set, repr(node))
def check_selectors(self, selectors: list[Selector], node: DOMNode) -> bool:
SAME = CombinatorType.SAME
DESCENDENT = CombinatorType.DESCENDENT
CHILD = CombinatorType.CHILD
css_path = node.css_path
path_count = len(css_path)
selector_count = len(selectors)
stack: list[tuple[int, int]] = [(0, 0)]
push = stack.append
pop = stack.pop
selector_index = 0
while stack:
selector_index, node_index = stack[-1]
if selector_index == selector_count:
return node_index + 1 == path_count
if node_index == path_count:
pop()
continue
selector = selectors[selector_index]
path_node = css_path[node_index]
combinator = selector.combinator
stack[-1] = (selector_index, node_index)
if combinator == SAME:
# Check current node again
if selector.check(path_node):
stack[-1] = (selector_index + 1, node_index + selector.advance)
else:
pop()
elif combinator == DESCENDENT:
# Find a matching descendent
if selector.check(path_node):
pop()
push((selector_index + 1, node_index + selector.advance + 1))
push((selector_index + 1, node_index + selector.advance))
else:
stack[-1] = (selector_index, node_index + selector.advance + 1)
elif combinator == CHILD:
# Match the next node
if selector.check(path_node):
stack[-1] = (selector_index + 1, node_index + selector.advance)
else:
pop()
return False
# def search(selector_index: int, node_index: int) -> bool:
# nodes = iter(enumerate(css_path[node_index:], node_index))
# try:
# node_index, node = next(nodes)
# for selector_index in range(selector_index, selector_count):
# selector = selectors[selector_index]
# combinator = selector.combinator
# if combinator == SAME:
# # Check current node again
# if not selector.check(node):
# return False
# elif combinator == DESCENDENT:
# # Find a matching descendent
# while True:
# node_index, node = next(nodes)
# if selector.check(node) and search(
# selector_index + 1, node_index
# ):
# break
# elif combinator == CHILD:
# # Match the next node
# node_index, node = next(nodes)
# if not selector.check(node):
# return False
# except StopIteration:
# return False
# return True
# return search(0, 0)
if __name__ == "__main__":
@@ -171,7 +95,7 @@ if __name__ == "__main__":
main_view.add_child(sub_view)
tooltip = Widget(id="tooltip")
tooltip.add_class("float")
tooltip.add_class("float", "transient")
sub_view.add_child(tooltip)
help = Widget(id="markdown")
@@ -196,7 +120,22 @@ if __name__ == "__main__":
"""
print(app._all_children())
from .query import DOMQuery
# print(DOMQuery(selector="App", nodes=[sub_view]))
tests = [
"App > View",
"Widget.float",
".float.transient",
]
for test in tests:
print("")
print(f"[b]{test}")
print(app.query(test))
# print(app.query("View"))
# stylesheet = Stylesheet()
# stylesheet.parse(CSS)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Iterable
from typing import Iterable, Iterator, TYPE_CHECKING
from rich.highlighter import ReprHighlighter
import rich.repr
@@ -12,6 +12,10 @@ from .message_pump import MessagePump
from ._node_list import NodeList
if TYPE_CHECKING:
from .css.query import DOMQuery
@rich.repr.auto
class DOMNode(MessagePump):
"""A node in a hierarchy of things forming the UI.
@@ -84,11 +88,28 @@ class DOMNode(MessagePump):
self.children._append(node)
node.set_parent(self)
def _all_children(self) -> Iterable[DOMNode]:
children = {self, *self.children}
for child in self.children:
children.update(child._all_children())
return children
def walk_children(self, with_self: bool = True) -> Iterable[DOMNode]:
stack: list[Iterator[DOMNode]] = [iter(self.children)]
pop = stack.pop
push = stack.append
if with_self:
yield self
while stack:
node = next(stack[-1], None)
if node is None:
pop()
else:
yield node
if node.children:
push(iter(node.children))
def query(self, selector: str) -> DOMQuery:
from .css.query import DOMQuery
return DOMQuery(self, selector)
def has_class(self, *class_names: str) -> bool:
return self._classes.issuperset(class_names)