This commit is contained in:
Will McGugan
2021-10-28 11:44:14 +01:00
parent 3e74b5e0b0
commit 6ca8da3e14
9 changed files with 143 additions and 85 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

61
src/textual/dom.py Normal file
View File

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

View File

@@ -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:
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]
self.name = name or f"_{class_name}{_count}"
self._id = id
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)