From 8f3d32059195c39a071f7ec8635002f5edd388d5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 11 Dec 2023 12:14:38 +0000 Subject: [PATCH] Faster css (#3844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * faster css * simplify * ws * superfluous function * simplify selector names, change to set * changelog * micro op * quicken * Update src/textual/strip.py Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- CHANGELOG.md | 3 +- src/textual/app.py | 4 +- src/textual/css/model.py | 42 ++++------ src/textual/css/styles.py | 2 +- src/textual/css/stylesheet.py | 141 +++++++++++++++------------------- src/textual/dom.py | 9 +-- src/textual/strip.py | 2 +- 7 files changed, 89 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5eb24d0a..be0a9b46dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed -- Removed renderables/align.py which was no longer used +- Removed renderables/align.py which was no longer used. ### Changed - Dropped ALLOW_CHILDREN flag introduced in 0.43.0 https://github.com/Textualize/textual/pull/3814 - Widgets with an auto height in an auto height container will now expand if they have no siblings https://github.com/Textualize/textual/pull/3814 +- Breaking change: Removed `limit_rules` from Stylesheet.apply https://github.com/Textualize/textual/pull/3844 ### Added diff --git a/src/textual/app.py b/src/textual/app.py index 7309841dd6..b10c7215d9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2246,9 +2246,9 @@ async def invoke_ready_callback() -> None: Reactive._initialize_object(self) - self.stylesheet.update(self) + self.stylesheet.apply(self) if self.screen is not default_screen: - self.stylesheet.update(default_screen) + self.stylesheet.apply(default_screen) await self.animator.start() diff --git a/src/textual/css/model.py b/src/textual/css/model.py index cf67bd9ddd..6b07e1bb81 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -161,6 +161,7 @@ class RuleSet: is_default_rules: bool = False tie_breaker: int = 0 selector_names: set[str] = field(default_factory=set) + pseudo_classes: set[str] = field(default_factory=set) def __hash__(self): return id(self) @@ -205,31 +206,20 @@ def _post_parse(self) -> None: type_type = SelectorType.TYPE universal_type = SelectorType.UNIVERSAL - update_selectors = self.selector_names.update + add_selector = self.selector_names.add + add_pseudo_classes = self.pseudo_classes.update for selector_set in self.selector_set: - 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 - ) + for selector in selector_set.selectors: + add_pseudo_classes(selector.pseudo_classes) + + selector = selector_set.selectors[-1] + selector_type = selector.type + if selector_type == universal_type: + add_selector("*") + elif selector_type == type_type: + add_selector(selector.name) + elif selector_type == class_type: + add_selector(f".{selector.name}") + elif selector_type == id_type: + add_selector(f"#{selector.name}") diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 0c3d0772e5..88dd46c11d 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -757,7 +757,7 @@ def extract_rules( A list containing a tuple of , . """ is_important = self.important.__contains__ - rules = [ + rules: list[tuple[str, Specificity6, Any]] = [ ( rule_name, ( diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index b7d1b5b82d..f52c55e1d1 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -335,6 +335,7 @@ def add_source( return self.source[read_from] = CssSource(css, is_default_css, tie_breaker, scope) self._require_parse = True + self._rules_map = None def parse(self) -> None: """Parse the source in the stylesheet. @@ -416,7 +417,6 @@ def apply( self, node: DOMNode, *, - limit_rules: set[RuleSet] | None = None, animate: bool = False, ) -> None: """Apply the stylesheet to a DOM node. @@ -440,15 +440,21 @@ def apply( _check_rule = self._check_rule css_path_nodes = node.css_path_nodes - rules: Iterable[RuleSet] - if limit_rules is not None: - rules = [rule for rule in reversed(self.rules) if rule in limit_rules] - else: - rules = reversed(self.rules) + rules_map = self.rules_map + + # Discard rules which are not applicable early + limit_rules = { + rule + for name in rules_map.keys() & node._selector_names + for rule in rules_map[name] + } + rules = [rule for rule in reversed(self.rules) if rule in limit_rules] # Collect the rules defined in the stylesheet - node._has_hover_style = False - node._has_focus_within = False + node._has_hover_style = any("hover" in rule.pseudo_classes for rule in rules) + node._has_focus_within = any( + "focus-within" in rule.pseudo_classes for rule in rules + ) # Rules that may be set to the special value `initial` initial: set[str] = set() @@ -458,10 +464,6 @@ def apply( for rule in rules: is_default_rules = rule.is_default_rules tie_breaker = rule.tie_breaker - if ":hover" in rule.selector_names: - node._has_hover_style = True - if ":focus-within" in rule.selector_names: - node._has_focus_within = True 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 @@ -473,52 +475,52 @@ def apply( initial.add(key) 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( - RulesMap, - { - name: max(specificity_rules, key=get_first_item)[1] - for name, specificity_rules in rule_attributes.items() - }, - ) + if rule_attributes: + # For each rule declared for this node, keep only the most specific one + get_first_item = itemgetter(0) + node_rules: RulesMap = cast( + RulesMap, + { + name: max(specificity_rules, key=get_first_item)[1] + for name, specificity_rules in rule_attributes.items() + }, + ) - # Set initial values - for initial_rule_name in initial: - # Rules with a value of None should be set to the default value - if node_rules[initial_rule_name] is None: # type: ignore[literal-required] - # Exclude non default values - # rule[0] is the specificity, rule[0][0] is 0 for default rules - default_rules = [ - rule - for rule in rule_attributes[initial_rule_name] - if not rule[0][0] - ] - if default_rules: - # There is a default value - new_value = max(default_rules, key=get_first_item)[1] - node_rules[initial_rule_name] = new_value # type: ignore[literal-required] - else: - # No default value - initial_defaults.add(initial_rule_name) - - # Rules in DEFAULT_CSS set to initial - for initial_rule_name in initial_defaults: - if node_rules[initial_rule_name] is None: # type: ignore[literal-required] - default_rules = [ - rule for rule in rule_attributes[initial_rule_name] if rule[0][0] - ] - if default_rules: - # There is a default value - rule_value = max(default_rules, key=get_first_item)[1] - else: - rule_value = getattr(_DEFAULT_STYLES, initial_rule_name) - node_rules[initial_rule_name] = rule_value # type: ignore[literal-required] - - self.replace_rules(node, node_rules, animate=animate) + # Set initial values + for initial_rule_name in initial: + # Rules with a value of None should be set to the default value + if node_rules[initial_rule_name] is None: # type: ignore[literal-required] + # Exclude non default values + # rule[0] is the specificity, rule[0][0] is 0 for default rules + default_rules = [ + rule + for rule in rule_attributes[initial_rule_name] + if not rule[0][0] + ] + if default_rules: + # There is a default value + new_value = max(default_rules, key=get_first_item)[1] + node_rules[initial_rule_name] = new_value # type: ignore[literal-required] + else: + # No default value + initial_defaults.add(initial_rule_name) + + # Rules in DEFAULT_CSS set to initial + for initial_rule_name in initial_defaults: + if node_rules[initial_rule_name] is None: # type: ignore[literal-required] + default_rules = [ + rule + for rule in rule_attributes[initial_rule_name] + if rule[0][0] + ] + if default_rules: + # There is a default value + rule_value = max(default_rules, key=get_first_item)[1] + else: + rule_value = getattr(_DEFAULT_STYLES, initial_rule_name) + node_rules[initial_rule_name] = rule_value # type: ignore[literal-required] + + self.replace_rules(node, node_rules, animate=animate) component_classes = node._get_component_classes() if component_classes: @@ -634,31 +636,14 @@ def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: nodes: Nodes to update. animate: Enable CSS animation. """ - rules_map = self.rules_map apply = self.apply - def get_rules(node: DOMNode) -> set[RuleSet]: - """Get set of rules for the given node.""" - return { - rule - for name in rules_map.keys() & node._selector_names - for rule in rules_map[name] - } - for node in nodes: - rules = get_rules(node) - if rules: - apply(node, limit_rules=rules, animate=animate) + apply(node, animate=animate) if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: - rules = get_rules(node.vertical_scrollbar) - if rules: - apply(node.vertical_scrollbar, limit_rules=rules) + apply(node.vertical_scrollbar) if node.show_horizontal_scrollbar: - rules = get_rules(node.horizontal_scrollbar) - if rules: - apply(node.horizontal_scrollbar, limit_rules=rules) + apply(node.horizontal_scrollbar) if node.show_horizontal_scrollbar and node.show_vertical_scrollbar: - rules = get_rules(node.scrollbar_corner) - if rules: - apply(node.scrollbar_corner, limit_rules=rules) + apply(node.scrollbar_corner) diff --git a/src/textual/dom.py b/src/textual/dom.py index e96578b17b..290c1da9b6 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -595,20 +595,19 @@ def css_path_nodes(self) -> list[DOMNode]: return result[::-1] @property - def _selector_names(self) -> list[str]: + def _selector_names(self) -> set[str]: """Get a set of selectors applicable to this widget. Returns: Set of selector names. """ - selectors: list[str] = [ + selectors: set[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}") + selectors.add(f"#{self._id}") return selectors @property diff --git a/src/textual/strip.py b/src/textual/strip.py index 82f2bca4b0..3f90414cae 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -199,7 +199,7 @@ def join(cls, strips: Iterable[Strip | None]) -> Strip: return strip def __bool__(self) -> bool: - return bool(self._segments) + return not not self._segments # faster than bool(...) def __iter__(self) -> Iterator[Segment]: return iter(self._segments)