Skip to content

Commit

Permalink
Faster css (#3844)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

---------

Co-authored-by: Rodrigo Girão Serrão <[email protected]>
  • Loading branch information
willmcgugan and rodrigogiraoserrao authored Dec 11, 2023
1 parent a2a6f56 commit 8f3d320
Show file tree
Hide file tree
Showing 7 changed files with 89 additions and 114 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
42 changes: 16 additions & 26 deletions src/textual/css/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}")
2 changes: 1 addition & 1 deletion src/textual/css/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,7 @@ def extract_rules(
A list containing a tuple of <RULE NAME>, <SPECIFICITY> <RULE VALUE>.
"""
is_important = self.important.__contains__
rules = [
rules: list[tuple[str, Specificity6, Any]] = [
(
rule_name,
(
Expand Down
141 changes: 63 additions & 78 deletions src/textual/css/stylesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
9 changes: 4 additions & 5 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/textual/strip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 8f3d320

Please sign in to comment.