diff --git a/pyproject.toml b/pyproject.toml index 7d52be98f..2d33c6782 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.7" -rich = "^10.7.0" +rich = "^10.12.0" #rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"} typing-extensions = { version = "^3.10.0", python = "<3.8" } diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 6fac9eff6..a7baae8c9 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Iterable, overload, TYPE_CHECKING +from typing import Iterator, overload, TYPE_CHECKING from weakref import ref import rich.repr @@ -59,7 +59,7 @@ class NodeList: del self._widget_refs[:] self.__widgets = None - def __iter__(self) -> Iterable[DOMNode]: + def __iter__(self) -> Iterator[DOMNode]: for widget_ref in self._widget_refs: widget = widget_ref() if widget is not None: diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 31273691c..28df2ae22 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -1,8 +1,7 @@ from __future__ import annotations -from typing import Callable -from rich import print +import rich.repr from dataclasses import dataclass, field from enum import Enum @@ -44,7 +43,8 @@ class Selector: type: SelectorType = SelectorType.TYPE pseudo_classes: list[str] = field(default_factory=list) specificity: Specificity3 = field(default_factory=lambda: (0, 0, 0)) - _name_lower: str = "" + _name_lower: str = field(default="", repr=False) + advance: int = 0 @property def css(self) -> str: @@ -101,11 +101,22 @@ class Declaration: tokens: list[Token] = field(default_factory=list) +@rich.repr.auto(angular=True) @dataclass class SelectorSet: selectors: list[Selector] = field(default_factory=list) specificity: Specificity3 = (0, 0, 0) + def __post_init__(self) -> None: + SAME = CombinatorType.SAME + for selector, next_selector in zip(self.selectors, self.selectors[1:]): + selector.advance = 0 if next_selector.combinator == SAME else 1 + + def __rich_repr__(self) -> rich.repr.Result: + selectors = RuleSet._selector_to_css(self.selectors) + yield selectors + yield None, self.specificity + @classmethod def from_selectors(cls, selectors: list[list[Selector]]) -> Iterable[SelectorSet]: for selector_list in selectors: @@ -124,7 +135,7 @@ class RuleSet: styles: Styles = field(default_factory=Styles) @classmethod - def selector_to_css(cls, selectors: list[Selector]) -> str: + def _selector_to_css(cls, selectors: list[Selector]) -> str: tokens: list[str] = [] for selector in selectors: if selector.combinator == CombinatorType.DESCENDENT: @@ -135,11 +146,14 @@ class RuleSet: return "".join(tokens).strip() @property - def css(self) -> str: - selectors = ", ".join( - self.selector_to_css(selector_set.selectors) + def selectors(self): + return ", ".join( + self._selector_to_css(selector_set.selectors) for selector_set in self.selector_set ) + + @property + def css(self) -> str: declarations = "\n".join(f" {line}" for line in self.styles.css_lines) - css = f"{selectors} {{\n{declarations}\n}}" + css = f"{self.selectors} {{\n{declarations}\n}}" return css diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 5af845316..9145c94dc 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -5,6 +5,7 @@ from rich import print from typing import Iterator, Iterable from .tokenize import tokenize, Token +from .tokenizer import EOFError from .model import ( Declaration, @@ -29,6 +30,54 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = { } +def parse_selectors(css_selectors: str) -> list[SelectorSet]: + + tokens = iter(tokenize(css_selectors)) + + get_selector = SELECTOR_MAP.get + combinator: CombinatorType | None = CombinatorType.DESCENDENT + selectors: list[Selector] = [] + rule_selectors: list[list[Selector]] = [] + + while True: + try: + token = next(tokens) + print(token) + except EOFError: + break + if token.name == "pseudo_class": + selectors[-1].pseudo_classes.append(token.value.lstrip(":")) + elif token.name == "whitespace": + if combinator is None or combinator == CombinatorType.SAME: + combinator = CombinatorType.DESCENDENT + elif token.name == "new_selector": + rule_selectors.append(selectors[:]) + selectors.clear() + combinator = None + elif token.name == "declaration_set_start": + break + elif token.name == "combinator_child": + combinator = CombinatorType.CHILD + else: + _selector, specificity = get_selector( + token.name, (SelectorType.TYPE, (0, 0, 0)) + ) + selectors.append( + Selector( + name=token.value.lstrip(".#"), + combinator=combinator or CombinatorType.DESCENDENT, + type=_selector, + specificity=specificity, + ) + ) + combinator = CombinatorType.SAME + if selectors: + rule_selectors.append(selectors[:]) + + selector_set = list(SelectorSet.from_selectors(rule_selectors)) + return selector_set + + def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: rule_set = RuleSet() @@ -109,37 +158,41 @@ def parse(css: str) -> Iterable[RuleSet]: yield from parse_rule_set(tokens, token) +# if __name__ == "__main__": +# test = """ + +# App View { +# text: red; +# } + +# .foo.bar baz:focus, #egg .foo.baz { +# /* ignore me, I'm a comment */ +# display: block; +# visibility: visible; +# border: solid green !important; +# outline: red; +# padding: 1 2; +# margin: 5; +# text: bold red on magenta +# text-color: green; +# text-background: white +# docks: foo bar bar +# dock-group: foo +# dock-edge: top +# offset-x: 4 +# offset-y: 5 +# }""" + +# from .stylesheet import Stylesheet + +# print(test) +# print() +# stylesheet = Stylesheet() +# stylesheet.parse(test) +# print(stylesheet) +# print() +# print(stylesheet.css) + + if __name__ == "__main__": - test = """ - -App View { - text: red; -} - -.foo.bar baz:focus, #egg .foo.baz { - /* ignore me, I'm a comment */ - display: block; - visibility: visible; - border: solid green !important; - outline: red; - padding: 1 2; - margin: 5; - text: bold red on magenta - text-color: green; - text-background: white - docks: foo bar bar - dock-group: foo - dock-edge: top - offset-x: 4 - offset-y: 5 -}""" - - from .stylesheet import Stylesheet - - print(test) - print() - stylesheet = Stylesheet() - stylesheet.parse(test) - print(stylesheet) - print() - print(stylesheet.css) + print(parse_selectors("Foo > Bar.baz { foo: bar")) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 3f49586d1..4550fa16c 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -4,7 +4,7 @@ import rich.repr from ..dom import DOMNode from .errors import StylesheetError -from .model import CombinatorType, RuleSet, Selector, SelectorType +from .model import CombinatorType, RuleSet, Selector from .parse import parse from .styles import Styles from .types import Specificity3 @@ -45,49 +45,100 @@ class Stylesheet: styles: list[tuple[Specificity3, Styles]] = [] for rule in self.rules: - print(rule) self.apply_rule(rule, node) def apply_rule(self, rule: RuleSet, node: DOMNode) -> None: for selector_set in rule.selector_set: if self.check_selectors(selector_set.selectors, node): - print(rule.css) + print(selector_set, repr(node)) def check_selectors(self, selectors: list[Selector], node: DOMNode) -> bool: - node_path = node.css_path - nodes = iter(node_path) - - node_siblings = next(nodes, None) - if node_siblings is None: - return False - node, siblings = node_siblings SAME = CombinatorType.SAME DESCENDENT = CombinatorType.DESCENDENT CHILD = CombinatorType.CHILD - try: - for selector in selectors: - if selector.combinator == SAME: - if not selector.check(node): - return False - elif selector.combinator == DESCENDENT: - while True: - node, siblings = next(nodes) - if selector.check(node): - break - elif selector.combinator == CHILD: - node, siblings = next(nodes) - if not selector.check(node): - return False - except StopIteration: - return False + css_path = node.css_path + path_count = len(css_path) + selector_count = len(selectors) - return True + 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__": + from rich.traceback import install + + install(show_locals=True) + class Widget(DOMNode): pass @@ -105,16 +156,24 @@ if __name__ == "__main__": widget1 = Widget(id="widget1") widget2 = Widget(id="widget2") - sidebar = Widget() + sidebar = Widget(id="sidebar") sidebar.add_class("float") - helpbar = Widget() + helpbar = Widget(id="helpbar") helpbar.add_class("float") main_view.add_child(widget1) main_view.add_child(widget2) main_view.add_child(sidebar) + sub_view = View(id="sub") + sub_view.add_class("-subview") + main_view.add_child(sub_view) + + tooltip = Widget(id="tooltip") + tooltip.add_class("float") + sub_view.add_child(tooltip) + help = Widget(id="markdown") help_view.add_child(help) help_view.add_child(helpbar) @@ -125,17 +184,21 @@ if __name__ == "__main__": CSS = """ +/* App > View { text: red; + }*/ + + App > View.-subview { + outline: heavy } - Widget.float { - - } """ - stylesheet = Stylesheet() - stylesheet.parse(CSS) + print(app._all_children()) - stylesheet.apply(sidebar) + # stylesheet = Stylesheet() + # stylesheet.parse(CSS) + + # stylesheet.apply(sub_view) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index ff2881777..be081e315 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -84,5 +84,4 @@ def tokenize(code: str) -> Iterable[Token]: elif name == "eof": break expect = get_state(name, expect) - print(token) yield token diff --git a/src/textual/dom.py b/src/textual/dom.py index 44dfc5528..0e50bac8d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Iterable + from rich.highlighter import ReprHighlighter import rich.repr from rich.pretty import Pretty @@ -54,14 +56,14 @@ class DOMNode(MessagePump): return self.__class__.__name__.lower() @property - def css_path(self) -> list[tuple[DOMNode, list[DOMNode]]]: - result: list[tuple[DOMNode, list[DOMNode]]] = [(self, self.children[:])] + def css_path(self) -> list[DOMNode]: + result: list[DOMNode] = [self] append = result.append node: DOMNode = self while isinstance(node._parent, DOMNode): node = node._parent - append((node, node.children[:])) + append(node) return result[::-1] @property @@ -82,6 +84,12 @@ 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 has_class(self, *class_names: str) -> bool: return self._classes.issuperset(class_names)