diff --git a/docs/examples/introduction/stopwatch.css b/docs/examples/introduction/stopwatch.css index 472dccc89..93678369c 100644 --- a/docs/examples/introduction/stopwatch.css +++ b/docs/examples/introduction/stopwatch.css @@ -51,4 +51,3 @@ Button { .started #reset { visibility: hidden } - diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3fab34aeb..8d5c299b7 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -183,6 +183,9 @@ class Compositor: # Note this may be a superset of self.map.keys() as some widgets may be invisible for various reasons self.widgets: set[Widget] = set() + # A lazy cache of visible (on screen) widgets + self._visible_widgets: set[Widget] | None = set() + # The top level widget self.root: Widget | None = None @@ -269,6 +272,7 @@ class Compositor: # Replace map and widgets self.map = map self.widgets = widgets + self._visible_widgets = None # Get a map of regions self.regions = { @@ -305,6 +309,22 @@ class Compositor: resized=resized_widgets, ) + @property + def visible_widgets(self) -> set[Widget]: + """Get a set of visible widgets. + + Returns: + set[Widget]: Widgets in the screen. + """ + if self._visible_widgets is None: + in_screen = self.size.region.__contains__ + self._visible_widgets = { + widget + for widget, (region, clip) in self.regions.items() + if in_screen(region) + } + return self._visible_widgets + def _arrange_root( self, root: Widget, size: Size ) -> tuple[CompositorMap, set[Widget]]: diff --git a/src/textual/app.py b/src/textual/app.py index 38afb8677..f56f39052 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -224,7 +224,7 @@ class App(Generic[ReturnType], DOMNode): self.design = DEFAULT_COLORS self.stylesheet = Stylesheet(variables=self.get_css_variables()) - self._require_stylesheet_update = False + self._require_stylesheet_update: set[DOMNode] = set() self.css_path = css_path or self.CSS_PATH self._registry: WeakSet[DOMNode] = WeakSet() @@ -714,13 +714,18 @@ class App(Generic[ReturnType], DOMNode): """ return self.screen.get_child(id) - def update_styles(self) -> None: + def update_styles(self, node: DOMNode | None = None) -> None: """Request update of styles. Should be called whenever CSS classes / pseudo classes change. """ - self._require_stylesheet_update = True + self._require_stylesheet_update.add(self.screen if node is None else node) + self.check_idle() + + def update_visible_styles(self) -> None: + """Update visible styles only.""" + self._require_stylesheet_update.update(self.screen.visible_widgets) self.check_idle() def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: @@ -1137,16 +1142,21 @@ class App(Generic[ReturnType], DOMNode): self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") - def on_mount(self) -> None: + def _on_mount(self) -> None: widgets = self.compose() if widgets: self.mount_all(widgets) - async def on_idle(self) -> None: + def _on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" if self._require_stylesheet_update: - self._require_stylesheet_update = False - self.stylesheet.update(self, animate=True) + nodes: set[DOMNode] = { + child + for node in self._require_stylesheet_update + for child in node.walk_children() + } + self._require_stylesheet_update.clear() + self.stylesheet.update_nodes(nodes, animate=True) def _register_child(self, parent: DOMNode, child: Widget) -> bool: if child not in self._registry: diff --git a/src/textual/css/model.py b/src/textual/css/model.py index cc960eedb..c5efebc15 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -159,9 +159,13 @@ class RuleSet: selector_set: list[SelectorSet] = field(default_factory=list) styles: Styles = field(default_factory=Styles) errors: list[tuple[Token, str]] = field(default_factory=list) - classes: set[str] = field(default_factory=set) + is_default_rules: bool = False tie_breaker: int = 0 + selector_names: set[str] = field(default_factory=set) + + def __hash__(self): + return id(self) @classmethod def _selector_to_css(cls, selectors: list[Selector]) -> str: @@ -195,11 +199,37 @@ class RuleSet: def _post_parse(self) -> None: """Called after the RuleSet is parsed.""" # Build a set of the class names that have been updated - update = self.classes.update + class_type = SelectorType.CLASS + id_type = SelectorType.ID + type_type = SelectorType.TYPE + universal_type = SelectorType.UNIVERSAL + + update_selectors = self.selector_names.update + for selector_set in self.selector_set: - update( + update_selectors( + "*" + for selector in selector_set.selectors + if selector.type == universal_type + ) + update_selectors( selector.name for selector in selector_set.selectors + if selector.type == type_type + ) + update_selectors( + f".{selector.name}" + for selector in selector_set.selectors if selector.type == class_type ) + update_selectors( + f"#{selector.name}" + for selector in selector_set.selectors + if selector.type == id_type + ) + update_selectors( + f":{pseudo_class}" + for selector in selector_set.selectors + for pseudo_class in selector.pseudo_classes + ) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 0ea08020c..849688c42 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -4,10 +4,10 @@ import os from collections import defaultdict from operator import itemgetter from pathlib import Path, PurePath -from typing import cast, Iterable, NamedTuple +from typing import Iterable, NamedTuple, cast import rich.repr -from rich.console import RenderableType, RenderResult, Console, ConsoleOptions +from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.markup import render from rich.padding import Padding from rich.panel import Panel @@ -15,17 +15,18 @@ from rich.style import Style from rich.syntax import Syntax from rich.text import Text -from textual.widget import Widget +from .. import messages +from .._profile import timer +from ..dom import DOMNode +from ..widget import Widget from .errors import StylesheetError from .match import _check_selectors from .model import RuleSet from .parse import parse from .styles import RulesMap, Styles -from .tokenize import tokenize_values, Token +from .tokenize import Token, tokenize_values from .tokenizer import TokenError from .types import Specificity3, Specificity6 -from ..dom import DOMNode -from .. import messages class StylesheetParseError(StylesheetError): @@ -135,6 +136,7 @@ class CssSource(NamedTuple): class Stylesheet: def __init__(self, *, variables: dict[str, str] | None = None) -> None: self._rules: list[RuleSet] = [] + self._rules_map: dict[str, list[RuleSet]] | None = None self.variables = variables or {} self.source: dict[str, CssSource] = {} self._require_parse = False @@ -144,12 +146,32 @@ class Stylesheet: @property def rules(self) -> list[RuleSet]: + """List of rule sets. + + Returns: + list[RuleSet]: List of rules sets for this stylesheet. + """ if self._require_parse: self.parse() self._require_parse = False assert self._rules is not None return self._rules + @property + def rules_map(self) -> dict[str, list[RuleSet]]: + """Structure that maps a selector on to a list of rules. + + Returns: + dict[str, list[RuleSet]]: Mapping of selector to rule sets. + """ + if self._rules_map is None: + rules_map: dict[str, list[RuleSet]] = defaultdict(list) + for rule in self.rules: + for name in rule.selector_names: + rules_map[name].append(rule) + self._rules_map = dict(rules_map) + return self._rules_map + @property def css(self) -> str: return "\n\n".join(rule_set.css for rule_set in self.rules) @@ -283,6 +305,7 @@ class Stylesheet: add_rules(css_rules) self._rules = rules self._require_parse = False + self._rules_map = None def reparse(self) -> None: """Re-parse source, applying new variables. @@ -300,15 +323,24 @@ class Stylesheet: ) stylesheet.parse() self._rules = stylesheet.rules + self._rules_map = None self.source = stylesheet.source @classmethod - def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]: + def _check_rule( + cls, rule: RuleSet, css_path_nodes: list[DOMNode] + ) -> Iterable[Specificity3]: for selector_set in rule.selector_set: - if _check_selectors(selector_set.selectors, node.css_path_nodes): + if _check_selectors(selector_set.selectors, css_path_nodes): yield selector_set.specificity - def apply(self, node: DOMNode, animate: bool = False) -> None: + def apply( + self, + node: DOMNode, + *, + limit_rules: set[RuleSet] | None = None, + animate: bool = False, + ) -> None: """Apply the stylesheet to a DOM node. Args: @@ -319,32 +351,34 @@ class Stylesheet: rule will be applied. animate (bool, optional): Animate changed rules. Defaults to ``False``. """ - - # TODO: Need to optimize to make applying stylesheet more efficient - # I think we can pre-calculate which rules may be applicable to a given node - # Dictionary of rule attribute names e.g. "text_background" to list of tuples. # The tuples contain the rule specificity, and the value for that rule. # We can use this to determine, for a given rule, whether we should apply it # or not by examining the specificity. If we have two rules for the # same attribute, then we can choose the most specific rule and use that. - rule_attributes: dict[str, list[tuple[Specificity6, object]]] - rule_attributes = {} + rule_attributes: defaultdict[str, list[tuple[Specificity6, object]]] + rule_attributes = defaultdict(list) _check_rule = self._check_rule + css_path_nodes = node.css_path_nodes + rules: Iterable[RuleSet] + if limit_rules: + rules = [rule for rule in reversed(self.rules) if rule in limit_rules] + else: + rules = reversed(self.rules) # Collect the rules defined in the stylesheet - for rule in reversed(self.rules): + for rule in rules: is_default_rules = rule.is_default_rules tie_breaker = rule.tie_breaker - for base_specificity in _check_rule(rule, node): + for base_specificity in _check_rule(rule, css_path_nodes): for key, rule_specificity, value in rule.styles.extract_rules( base_specificity, is_default_rules, tie_breaker ): - rule_attributes.setdefault(key, []).append( - (rule_specificity, value) - ) + rule_attributes[key].append((rule_specificity, value)) + if not rule_attributes: + return # For each rule declared for this node, keep only the most specific one get_first_item = itemgetter(0) node_rules: RulesMap = cast( @@ -354,7 +388,6 @@ class Stylesheet: for name, specificity_rules in rule_attributes.items() }, ) - self.replace_rules(node, node_rules, animate=animate) node._component_styles.clear() @@ -381,7 +414,7 @@ class Stylesheet: base_styles = styles.base # Styles currently used on new rules - modified_rule_keys = {*base_styles.get_rules().keys(), *rules.keys()} + modified_rule_keys = base_styles.get_rules().keys() | rules.keys() # Current render rules (missing rules are filled with default) current_render_rules = styles.get_render_rules() @@ -434,10 +467,34 @@ class Stylesheet: node.post_message_no_wait(messages.StylesUpdated(sender=node)) def update(self, root: DOMNode, animate: bool = False) -> None: - """Update a node and its children.""" + """Update styles on node and its children. + + Args: + root (DOMNode): Root note to update. + animate (bool, optional): Enable CSS animation. Defaults to False. + """ + + self.update_nodes(root.walk_children(), animate=animate) + + def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: + """Update styles for nodes. + + Args: + nodes (DOMNode): Nodes to update. + animate (bool, optional): Enable CSS animation. Defaults to False. + """ + + rules_map = self.rules_map apply = self.apply - for node in root.walk_children(): - apply(node, animate=animate) + + for node in nodes: + rules = { + rule + for name in node._selector_names + if name in rules_map + for rule in rules_map[name] + } + apply(node, limit_rules=rules, animate=animate) if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: apply(node.vertical_scrollbar) diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 63c59f3d8..802bd686a 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -163,6 +163,7 @@ class DevtoolsClient: if isinstance(log, str): await websocket.send_str(log) else: + assert isinstance(log, bytes) await websocket.send_bytes(log) log_queue.task_done() diff --git a/src/textual/dom.py b/src/textual/dom.py index f2c3ca31e..984ac8f16 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -87,6 +87,7 @@ class DOMNode(MessagePump): self._auto_refresh: float | None = None self._auto_refresh_timer: Timer | None = None + self._css_types = {cls.__name__ for cls in self._css_bases(self.__class__)} super().__init__() @@ -311,6 +312,23 @@ class DOMNode(MessagePump): append(node) return result[::-1] + @property + def _selector_names(self) -> list[str]: + """Get a set of selectors applicable to this widget. + + Returns: + set[str]: Set of selector names. + """ + selectors: list[str] = [ + "*", + *(f".{class_name}" for class_name in self._classes), + *(f":{class_name}" for class_name in self.get_pseudo_classes()), + *self._css_types, + ] + if self._id is not None: + selectors.append(f"#{self._id}") + return selectors + @property def display(self) -> bool: """ @@ -699,7 +717,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self.app, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass @@ -715,7 +733,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self.app, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass @@ -731,7 +749,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self.app, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass diff --git a/src/textual/screen.py b/src/textual/screen.py index 2cbd12440..303c76ea0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -63,6 +63,16 @@ class Screen(Widget): ) return self._update_timer + @property + def widgets(self) -> list[Widget]: + """Get all widgets.""" + return list(self._compositor.map.keys()) + + @property + def visible_widgets(self) -> list[Widget]: + """Get a list of visible widgets.""" + return list(self._compositor.visible_widgets) + def watch_dark(self, dark: bool) -> None: pass diff --git a/src/textual/widget.py b/src/textual/widget.py index 32cda2fb2..86a46dc8e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1227,11 +1227,11 @@ class Widget(DOMNode): def watch_mouse_over(self, value: bool) -> None: """Update from CSS if mouse over state changes.""" - self.app.update_styles() + self.app.update_styles(self) def watch_has_focus(self, value: bool) -> None: """Update from CSS if has focus state changes.""" - self.app.update_styles() + self.app.update_styles(self) def size_updated( self, size: Size, virtual_size: Size, container_size: Size