From 6ca8da3e14c6d1a90be7ed849c6ee991e6d0e68a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 28 Oct 2021 11:44:14 +0100 Subject: [PATCH] eof --- .../{_widget_list.py => _node_list.py} | 19 +++--- src/textual/app.py | 22 ++++--- src/textual/css/model.py | 16 ++--- src/textual/css/parse.py | 2 +- src/textual/css/styles.py | 4 +- src/textual/css/stylesheet.py | 38 ++++++++++- src/textual/css/types.py | 2 + src/textual/dom.py | 61 ++++++++++++++++++ src/textual/widget.py | 64 +++---------------- 9 files changed, 143 insertions(+), 85 deletions(-) rename src/textual/{_widget_list.py => _node_list.py} (78%) create mode 100644 src/textual/dom.py diff --git a/src/textual/_widget_list.py b/src/textual/_node_list.py similarity index 78% rename from src/textual/_widget_list.py rename to src/textual/_node_list.py index 832b70725..6fac9eff6 100644 --- a/src/textual/_widget_list.py +++ b/src/textual/_node_list.py @@ -7,10 +7,11 @@ import rich.repr if TYPE_CHECKING: from .widget import Widget + from .dom import DOMNode @rich.repr.auto -class WidgetList: +class NodeList: """ A container for widgets that forms one level of hierarchy. @@ -19,8 +20,8 @@ class WidgetList: """ def __init__(self) -> None: - self._widget_refs: list[ref[Widget]] = [] - self.__widgets: list[Widget] | None = [] + self._widget_refs: list[ref[DOMNode]] = [] + self.__widgets: list[DOMNode] | None = [] def __rich_repr__(self) -> rich.repr.Result: yield self._widgets @@ -32,7 +33,7 @@ class WidgetList: return widget in self._widgets @property - def _widgets(self) -> list[Widget]: + def _widgets(self) -> list[DOMNode]: if self.__widgets is None: self.__widgets = list( filter(None, [widget_ref() for widget_ref in self._widget_refs]) @@ -49,7 +50,7 @@ class WidgetList: ], ) - def _append(self, widget: Widget) -> None: + def _append(self, widget: DOMNode) -> None: if widget not in self._widgets: self._widget_refs.append(ref(widget)) self.__widgets = None @@ -58,21 +59,21 @@ class WidgetList: del self._widget_refs[:] self.__widgets = None - def __iter__(self) -> Iterable[Widget]: + def __iter__(self) -> Iterable[DOMNode]: for widget_ref in self._widget_refs: widget = widget_ref() if widget is not None: yield widget @overload - def __getitem__(self, index: int) -> Widget: + def __getitem__(self, index: int) -> DOMNode: ... @overload - def __getitem__(self, index: slice) -> list[Widget]: + def __getitem__(self, index: slice) -> list[DOMNode]: ... - def __getitem__(self, index: int | slice) -> Widget | list[Widget]: + def __getitem__(self, index: int | slice) -> DOMNode | list[DOMNode]: self._prune() assert self._widgets is not None return self._widgets[index] diff --git a/src/textual/app.py b/src/textual/app.py index cb928e0e7..23abd1a8c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -18,6 +18,7 @@ from rich.tree import Tree from . import events from . import actions +from .dom import DOMNode from ._animator import Animator from .binding import Bindings, NoBinding from .geometry import Offset, Region @@ -57,7 +58,7 @@ class ActionError(Exception): @rich.repr.auto -class App(MessagePump): +class App(DOMNode): """The base class for Textual Applications""" KEYS: ClassVar[dict[str, str]] = {} @@ -88,7 +89,6 @@ class App(MessagePump): self._title = title self._layout = DockLayout() self._view_stack: list[DockView] = [] - self.children: set[Widget] = set() self.focused: Widget | None = None self.mouse_over: Widget | None = None @@ -115,6 +115,8 @@ class App(MessagePump): self.css_file = css_file self.css = css + self.registry: set[MessagePump] = set() + super().__init__() title: Reactive[str] = Reactive("Textual") @@ -150,6 +152,10 @@ class App(MessagePump): add_children(branch, self.view) return tree + @property + def css_type(self) -> str: + return "app" + def load_css(self, filename: str) -> None: pass @@ -340,8 +346,8 @@ class App(MessagePump): self.log_file.close() def register(self, child: MessagePump, parent: MessagePump) -> bool: - if child not in self.children: - self.children.add(child) + if child not in self.registry: + self.registry.add(child) child.set_parent(parent) child.start_messages() child.post_message_no_wait(events.Mount(sender=parent)) @@ -349,15 +355,15 @@ class App(MessagePump): return False def is_mounted(self, widget: Widget) -> bool: - return widget in self.children + return widget in self.registry async def close_all(self) -> None: - while self.children: - child = self.children.pop() + while self.registry: + child = self.registry.pop() await child.close_messages() async def remove(self, child: MessagePump) -> None: - self.children.remove(child) + self.registry.remove(child) async def shutdown(self): driver = self._driver diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 349d15bf6..936dff803 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -8,6 +8,7 @@ from typing import Iterable from .styles import Styles from .tokenize import Token +from .types import Specificity3 class SelectorType(Enum): @@ -33,18 +34,18 @@ class Location: class Selector: name: str combinator: CombinatorType = CombinatorType.SAME - selector: SelectorType = SelectorType.TYPE + type: SelectorType = SelectorType.TYPE pseudo_classes: list[str] = field(default_factory=list) - specificity: tuple[int, int, int] = field(default_factory=lambda: (0, 0, 0)) + specificity: Specificity3 = field(default_factory=lambda: (0, 0, 0)) @property def css(self) -> str: psuedo_suffix = "".join(f":{name}" for name in self.pseudo_classes) - if self.selector == SelectorType.UNIVERSAL: + if self.type == SelectorType.UNIVERSAL: return "*" - elif self.selector == SelectorType.TYPE: + elif self.type == SelectorType.TYPE: return f"{self.name}{psuedo_suffix}" - elif self.selector == SelectorType.CLASS: + elif self.type == SelectorType.CLASS: return f".{self.name}{psuedo_suffix}" else: return f"#{self.name}{psuedo_suffix}" @@ -55,14 +56,11 @@ class Declaration: name: str tokens: list[Token] = field(default_factory=list) - def process(self): - raise NotImplementedError - @dataclass class SelectorSet: selectors: list[Selector] = field(default_factory=list) - specificity: tuple[int, int, int] = (0, 0, 0) + specificity: Specificity3 = (0, 0, 0) @classmethod def from_selectors(cls, selectors: list[list[Selector]]) -> Iterable[SelectorSet]: diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 4701819b6..57605af5d 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -59,7 +59,7 @@ def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: Selector( name=token.value.lstrip(".#"), combinator=combinator, - selector=_selector, + type=_selector, specificity=specificity, ) ) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index e892ca1d2..289abed9e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -113,12 +113,12 @@ class Styles: def extract_rules( self, specificity: tuple[int, int, int] - ) -> dict[str, tuple[object, tuple[int, int, int, int]]]: + ) -> dict[str, tuple[tuple[int, int, int, int], object]]: is_important = self.important.__contains__ return { rule_name: ( - getattr(self, rule_name), (int(is_important(rule_name)), *specificity), + getattr(self, rule_name), ) for rule_name in RULE_NAMES if getattr(self, f"_rule_{rule_name}") is not None diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 6a7ec4543..bb2bcb08a 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -2,9 +2,14 @@ from __future__ import annotations import rich.repr +from ..dom import DOMNode from .errors import StylesheetError -from .model import RuleSet +from .model import CombinatorType, RuleSet, Selector, SelectorType from .parse import parse +from .styles import Styles +from .types import Specificity3 + +from ..widget import Widget @rich.repr.auto @@ -37,3 +42,34 @@ class Stylesheet: except Exception as error: raise StylesheetError(f"failed to parse css; {error}") from None self.rules.extend(rules) + + def apply(self, node: DOMNode) -> None: + styles: list[tuple[Specificity3, Styles]] = [] + + for rule in self.rules: + self.apply_rule(rule, node) + + def apply_rule(self, rule: RuleSet, node: DOMNode) -> None: + for selector_set in rule.selector_set: + self.check_selectors(selector_set.selectors, node) + + def check_selectors(self, selectors: list[Selector], node: DOMNode) -> bool: + node_path = node.css_path + nodes = iter(node_path) + + node, siblings = next(nodes) + + for selector in selectors: + if selector.type == SelectorType.UNIVERSAL: + continue + elif selector.type == SelectorType.TYPE: + while node.css_type != selector.name: + node, siblings = next(nodes) + if node is None: + return False + elif selector.type == SelectorType.CLASS: + while node.css_type != selector.name: + node, siblings = next(nodes) + if node is None: + return False + return True diff --git a/src/textual/css/types.py b/src/textual/css/types.py index debaa58cc..2e1748e74 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -15,3 +15,5 @@ else: Visibility = Literal["visible", "hidden", "initial", "inherit"] Display = Literal["block", "none"] EdgeStyle = Tuple[str, Style] +Specificity3 = Tuple[int, int, int] +Specificity4 = Tuple[int, int, int, int] diff --git a/src/textual/dom.py b/src/textual/dom.py new file mode 100644 index 000000000..fe31b5273 --- /dev/null +++ b/src/textual/dom.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import rich.repr + +from .css.styles import Styles +from .message_pump import MessagePump +from ._node_list import NodeList + + +@rich.repr.auto +class DOMNode(MessagePump): + def __init__(self, name: str | None = None, id: str | None = None) -> None: + self._name = name + self._id = id + self._class_names: set[str] = set() + self.children = NodeList() + self.styles: Styles = Styles() + super().__init__() + + @property + def id(self) -> str | None: + return self._id + + @property + def name(self) -> str | None: + return self._name + + @property + def class_names(self) -> frozenset[str]: + return frozenset(self._class_names) + + @property + def css_type(self) -> str: + return self.__class__.__name__.lower() + + @property + def css_path(self) -> list[tuple[DOMNode, list[DOMNode]]]: + result: list[tuple[DOMNode, list[DOMNode]]] = [] + append = result.append + + # TODO: + node: DOMNode = self + while isinstance(node._parent, DOMNode): + append((node, node.children[:])) + node = node._parent + return result[::-1] + + def has_class(self, *class_names: str) -> bool: + return self._class_names.issuperset(class_names) + + def add_class(self, *class_names: str) -> None: + """Add class names.""" + self._class_names.update(class_names) + + def remove_class(self, *class_names: str) -> None: + """Remove class names""" + self._class_names.difference_update(class_names) + + def toggle_class(self, *class_names: str) -> None: + """Toggle class names""" + self._class_names.symmetric_difference_update(class_names) diff --git a/src/textual/widget.py b/src/textual/widget.py index cb741ce30..87a0404c2 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -27,14 +27,12 @@ from . import errors from ._animator import BoundAnimator from ._border import Border, BORDER_STYLES from ._callback import invoke -from ._widget_list import WidgetList +from .dom import DOMNode from ._context import active_app from .geometry import Size, Spacing, SpacingDimensions from .message import Message -from .message_pump import MessagePump from .messages import Layout, Update from .reactive import Reactive, watch -from .css.styles import Styles from ._types import Lines if TYPE_CHECKING: @@ -58,18 +56,17 @@ class RenderCache(NamedTuple): @rich.repr.auto -class Widget(MessagePump): +class Widget(DOMNode): _counts: ClassVar[dict[str, int]] = {} can_focus: bool = False def __init__(self, name: str | None = None, id: str | None = None) -> None: - class_name = self.__class__.__name__ - Widget._counts.setdefault(class_name, 0) - Widget._counts[class_name] += 1 - _count = self._counts[class_name] - - self.name = name or f"_{class_name}{_count}" - self._id = id + if name is None: + class_name = self.__class__.__name__ + Widget._counts.setdefault(class_name, 0) + Widget._counts[class_name] += 1 + _count = self._counts[class_name] + name = f"{class_name}{_count}" self._size = Size(0, 0) self._repaint_required = False @@ -78,12 +75,8 @@ class Widget(MessagePump): self._reactive_watches: dict[str, Callable] = {} self.render_cache: RenderCache | None = None self.highlight_style: Style | None = None - self._class_names: set[str] = set() - self.styles: Styles = Styles() - self.children = WidgetList() - - super().__init__() + super().__init__(name=name, id=id) visible: Reactive[bool] = Reactive(True, layout=True) layout_size: Reactive[int | None] = Reactive(None, layout=True) @@ -144,45 +137,6 @@ class Widget(MessagePump): return widget raise errors.MissingWidget(f"Widget named {name!r} was not found in {self}") - @property - def id(self) -> str | None: - return self._id - - @property - def class_names(self) -> frozenset[str]: - return frozenset(self._class_names) - - @property - def _css_path(self) -> list[tuple[MessagePump, list[Widget]]]: - result: list[tuple[MessagePump, list[Widget]]] = [] - - # TODO: - widget: Widget = self - while isinstance(widget._parent, Widget): - result.append((widget, widget.children[:])) - widget = widget._parent - return result[::-1] - - def has_class(self, *class_names: str) -> bool: - return self._class_names.issuperset(class_names) - - def add_class(self, *class_names: str) -> None: - """Add class names.""" - self._class_names.update(class_names) - - def remove_class(self, *class_names: str) -> None: - """Remove class names""" - self._class_names.difference_update(class_names) - - def toggle_class(self, *class_names: str) -> None: - """Toggle class names""" - _class_names = self._class_names - for class_name in class_names: - if class_name in _class_names: - _class_names.discard(class_name) - else: - _class_names.add(class_name) - def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None: watch(self, attribute_name, callback)