mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #705 from Textualize/optimize-styles-apply
Optimize applying CSS styles
This commit is contained in:
@@ -51,4 +51,3 @@ Button {
|
||||
.started #reset {
|
||||
visibility: hidden
|
||||
}
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user