From 9a1cd48532322bbbe26990979abfa396a126c5ff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 11:06:24 +0100 Subject: [PATCH 01/13] visible widgets --- src/textual/_compositor.py | 17 ++++++++++++++++- src/textual/app.py | 4 ++++ src/textual/css/stylesheet.py | 18 ++++++++++++++++-- src/textual/screen.py | 10 ++++++++++ src/textual/widget.py | 2 +- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3fab34aeb..f7c5ae757 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -183,6 +183,8 @@ 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() + self._visible_widgets: set[Widget] | None = set() + # The top level widget self.root: Widget | None = None @@ -269,6 +271,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 +308,17 @@ class Compositor: resized=resized_widgets, ) + @property + def visible_widgets(self) -> set[Widget]: + 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]]: @@ -467,8 +481,9 @@ class Compositor: """Get the widget under the given point or None.""" # TODO: Optimize with some line based lookup contains = Region.contains + is_visible = self.visible_widgets.__contains__ for widget, cropped_region, region, *_ in self: - if contains(cropped_region, x, y): + if is_visible(widget) and contains(cropped_region, x, y): return widget, region raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})") diff --git a/src/textual/app.py b/src/textual/app.py index f564ae148..022ac2519 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -723,6 +723,10 @@ class App(Generic[ReturnType], DOMNode): self._require_stylesheet_update = True self.check_idle() + def update_visible_styles(self) -> None: + """Update visible styles only.""" + self.stylesheet.update_nodes(self.screen.visible_widgets) + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: """Mount widgets. Widgets specified as positional args, or keywords args. If supplied as keyword args they will be assigned an id of the key. diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 0ea08020c..834a154c8 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -434,9 +434,23 @@ 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()) + + 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. + """ apply = self.apply - for node in root.walk_children(): + for node in nodes: apply(node, animate=animate) if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: diff --git a/src/textual/screen.py b/src/textual/screen.py index 2cbd12440..c8e886d6e 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 visible widgets.""" + return list(self._compositor.map.keys()) + + @property + def visible_widgets(self) -> list[Widget]: + """Get 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 4c23063c0..acad9105f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1225,7 +1225,7 @@ 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_visible_styles() def watch_has_focus(self, value: bool) -> None: """Update from CSS if has focus state changes.""" From 9b8b4db02f93928ce924989367166da71dd43a16 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 11:20:04 +0100 Subject: [PATCH 02/13] update self --- src/textual/dom.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 86a509858..dc8d124b2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -699,7 +699,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self.app, animate=True) + self.app.stylesheet.update(self, animate=True) except NoActiveAppError: pass @@ -715,7 +715,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self.app, animate=True) + self.app.stylesheet.update(self, animate=True) except NoActiveAppError: pass @@ -731,7 +731,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self.app, animate=True) + self.app.stylesheet.update(self, animate=True) except NoActiveAppError: pass From 9960bd139a16af7cdec6323923c29e5908e52c7b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 11:58:05 +0100 Subject: [PATCH 03/13] narrow updates --- src/textual/app.py | 9 ++++++--- src/textual/css/stylesheet.py | 10 ++++++++-- src/textual/widget.py | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 022ac2519..b1a99b0ff 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -714,14 +714,17 @@ 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.check_idle() + if node is None: + self._require_stylesheet_update = True + self.check_idle() + else: + self.stylesheet.update(node, animate=True) def update_visible_styles(self) -> None: """Update visible styles only.""" diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 834a154c8..da82dec94 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -27,6 +27,8 @@ from .types import Specificity3, Specificity6 from ..dom import DOMNode from .. import messages +from .._profile import timer + class StylesheetParseError(StylesheetError): def __init__(self, errors: StylesheetErrors) -> None: @@ -354,7 +356,6 @@ class Stylesheet: for name, specificity_rules in rule_attributes.items() }, ) - self.replace_rules(node, node_rules, animate=animate) node._component_styles.clear() @@ -433,6 +434,7 @@ class Stylesheet: node.post_message_no_wait(messages.StylesUpdated(sender=node)) + @timer("update") def update(self, root: DOMNode, animate: bool = False) -> None: """Update styles on node and its children. @@ -440,8 +442,10 @@ class Stylesheet: root (DOMNode): Root note to update. animate (bool, optional): Enable CSS animation. Defaults to False. """ - self.update_nodes(root.walk_children()) + print("update", root) + self.update_nodes(root.walk_children(), animate=animate) + @timer("update_nodes") def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: """Update styles for nodes. @@ -450,6 +454,8 @@ class Stylesheet: animate (bool, optional): Enable CSS animation. Defaults to False. """ apply = self.apply + nodes = list(nodes) + print(len(nodes), "NODES") for node in nodes: apply(node, animate=animate) if isinstance(node, Widget) and node.is_scrollable: diff --git a/src/textual/widget.py b/src/textual/widget.py index 3e0ebcfb3..d5a25df7a 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_visible_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 From 6879f91989f578e541083e48b7e2513f335abc12 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 16:10:40 +0100 Subject: [PATCH 04/13] optimize stylesheet apply --- src/textual/css/model.py | 20 ++++++++++++++-- src/textual/css/stylesheet.py | 42 +++++++++++++++++++++++++++------- src/textual/devtools/client.py | 1 + 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index cc960eedb..d0ab6a702 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -160,6 +160,8 @@ class RuleSet: styles: Styles = field(default_factory=Styles) errors: list[tuple[Token, str]] = field(default_factory=list) classes: set[str] = field(default_factory=set) + pseudo_classes: set[str] = field(default_factory=set) + ids: set[str] = field(default_factory=set) is_default_rules: bool = False tie_breaker: int = 0 @@ -195,11 +197,25 @@ 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 + update_classes = self.classes.update + update_ids = self.ids.update + update_pseudo_classes = self.pseudo_classes.update class_type = SelectorType.CLASS + id_type = SelectorType.ID + for selector_set in self.selector_set: - update( + update_classes( selector.name for selector in selector_set.selectors if selector.type == class_type ) + update_ids( + selector.name + for selector in selector_set.selectors + if selector.type == id_type + ) + update_pseudo_classes( + 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 da82dec94..10b0ce301 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -305,9 +305,11 @@ class Stylesheet: 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: @@ -332,21 +334,47 @@ class Stylesheet: # 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_setdefault = rule_attributes.setdefault _check_rule = self._check_rule + css_path_nodes = node.css_path_nodes + + path_ids = {path_node._id for path_node in css_path_nodes if path_node._id} + path_classes = { + class_name + for path_node in css_path_nodes + for class_name in path_node.classes + } + path_pseudo_classes = { + pseudo_class + for path_node in css_path_nodes + for pseudo_class in path_node.get_pseudo_classes() + } + # Collect the rules defined in the stylesheet for rule in reversed(self.rules): + if rule.ids and not rule.ids.issubset(path_ids): + continue + if rule.classes and not rule.classes.issubset(path_classes): + continue + if rule.pseudo_classes and not rule.pseudo_classes.issubset( + path_pseudo_classes + ): + continue + 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_attributes_setdefault(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( @@ -382,7 +410,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() @@ -442,10 +470,9 @@ class Stylesheet: root (DOMNode): Root note to update. animate (bool, optional): Enable CSS animation. Defaults to False. """ - print("update", root) + self.update_nodes(root.walk_children(), animate=animate) - @timer("update_nodes") def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None: """Update styles for nodes. @@ -455,7 +482,6 @@ class Stylesheet: """ apply = self.apply nodes = list(nodes) - print(len(nodes), "NODES") for node in nodes: apply(node, animate=animate) if isinstance(node, Widget) and node.is_scrollable: 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() From 8b672e0e8d36cd540b89a8c223305d2764f9c4f5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 16:16:45 +0100 Subject: [PATCH 05/13] use defaultdict which is faster --- src/textual/css/stylesheet.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 10b0ce301..30913b419 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -332,9 +332,8 @@ class Stylesheet: # 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_setdefault = rule_attributes.setdefault + rule_attributes: defaultdict[str, list[tuple[Specificity6, object]]] + rule_attributes = defaultdict(list) _check_rule = self._check_rule @@ -369,9 +368,7 @@ class Stylesheet: 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 From 26ff8043b1b9af2715836216f6fc8ad93e06c1be Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 16:21:14 +0100 Subject: [PATCH 06/13] docstrings --- src/textual/_compositor.py | 5 +++++ src/textual/css/stylesheet.py | 1 - src/textual/screen.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f7c5ae757..faf822adc 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -310,6 +310,11 @@ class Compositor: @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 = { diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 30913b419..bdd3c1c53 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -459,7 +459,6 @@ class Stylesheet: node.post_message_no_wait(messages.StylesUpdated(sender=node)) - @timer("update") def update(self, root: DOMNode, animate: bool = False) -> None: """Update styles on node and its children. diff --git a/src/textual/screen.py b/src/textual/screen.py index c8e886d6e..303c76ea0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -65,12 +65,12 @@ class Screen(Widget): @property def widgets(self) -> list[Widget]: - """Get visible widgets.""" + """Get all widgets.""" return list(self._compositor.map.keys()) @property def visible_widgets(self) -> list[Widget]: - """Get visible widgets.""" + """Get a list of visible widgets.""" return list(self._compositor.visible_widgets) def watch_dark(self, dark: bool) -> None: From 520979bfe787f06911cdb885cbb270ae45c206fb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 16:22:37 +0100 Subject: [PATCH 07/13] Comment --- src/textual/_compositor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index faf822adc..503706a3a 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -183,6 +183,7 @@ 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 From c3489f9e35d582ba6005b6871db80f153cd81694 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 17:14:47 +0100 Subject: [PATCH 08/13] optimization --- src/textual/css/model.py | 22 +++++++++++----------- src/textual/css/stylesheet.py | 25 +++---------------------- src/textual/dom.py | 20 ++++++++++++++++++++ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index d0ab6a702..7c3440ece 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -159,11 +159,11 @@ 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) - pseudo_classes: set[str] = field(default_factory=set) + ids: set[str] = field(default_factory=set) is_default_rules: bool = False tie_breaker: int = 0 + selector_names: set[str] = field(default_factory=set) @classmethod def _selector_to_css(cls, selectors: list[Selector]) -> str: @@ -197,25 +197,25 @@ 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_classes = self.classes.update - update_ids = self.ids.update - update_pseudo_classes = self.pseudo_classes.update + class_type = SelectorType.CLASS id_type = SelectorType.ID + update_selectors = self.selector_names.update + for selector_set in self.selector_set: - update_classes( - selector.name + update_selectors( + f".{selector.name}" for selector in selector_set.selectors if selector.type == class_type ) - update_ids( - selector.name + update_selectors( + f"#{selector.name}" for selector in selector_set.selectors if selector.type == id_type ) - update_pseudo_classes( - pseudo_class + 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 bdd3c1c53..564ec9deb 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -323,10 +323,6 @@ 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 @@ -338,28 +334,13 @@ class Stylesheet: _check_rule = self._check_rule css_path_nodes = node.css_path_nodes - - path_ids = {path_node._id for path_node in css_path_nodes if path_node._id} - path_classes = { - class_name - for path_node in css_path_nodes - for class_name in path_node.classes - } - path_pseudo_classes = { - pseudo_class - for path_node in css_path_nodes - for pseudo_class in path_node.get_pseudo_classes() + selector_names = { + selector for node in css_path_nodes for selector in node._selector_names } # Collect the rules defined in the stylesheet for rule in reversed(self.rules): - if rule.ids and not rule.ids.issubset(path_ids): - continue - if rule.classes and not rule.classes.issubset(path_classes): - continue - if rule.pseudo_classes and not rule.pseudo_classes.issubset( - path_pseudo_classes - ): + if rule.selector_names and not rule.selector_names.issubset(selector_names): continue is_default_rules = rule.is_default_rules diff --git a/src/textual/dom.py b/src/textual/dom.py index dc8d124b2..e33fa780d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -144,6 +144,10 @@ class DOMNode(MessagePump): # Node bases are in reversed order so that the base class is lower priority return self._css_bases(self.__class__) + @property + def css_types(self) -> set[str]: + return {cls.__name__ for cls in self._css_bases(self.__class__)} + @classmethod def _css_bases(cls, base: Type[DOMNode]) -> Iterator[Type[DOMNode]]: """Get the DOMNode base classes, which inherit CSS. @@ -311,6 +315,22 @@ 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: """ From e19a0090f7f55565e7b2615a5f4f3e5cea8d2750 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 25 Aug 2022 17:17:25 +0100 Subject: [PATCH 09/13] further optimizations --- src/textual/dom.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index e33fa780d..1218820fd 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__() @@ -144,10 +145,6 @@ class DOMNode(MessagePump): # Node bases are in reversed order so that the base class is lower priority return self._css_bases(self.__class__) - @property - def css_types(self) -> set[str]: - return {cls.__name__ for cls in self._css_bases(self.__class__)} - @classmethod def _css_bases(cls, base: Type[DOMNode]) -> Iterator[Type[DOMNode]]: """Get the DOMNode base classes, which inherit CSS. @@ -325,7 +322,7 @@ class DOMNode(MessagePump): 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, + *self._css_types, ] if self._id is not None: selectors.append(f"#{self._id}") From f49b8d2fadc7104e24903156145e7f56d791a4da Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 10:12:59 +0100 Subject: [PATCH 10/13] narrow down rules --- docs/examples/introduction/stopwatch.css | 3 +++ docs/examples/introduction/stopwatch.py | 1 + src/textual/_compositor.py | 3 +-- src/textual/app.py | 25 ++++++++++++---------- src/textual/css/model.py | 16 +++++++++++++- src/textual/css/stylesheet.py | 27 ++++++++++++++++++++---- src/textual/dom.py | 7 +++--- 7 files changed, 61 insertions(+), 21 deletions(-) diff --git a/docs/examples/introduction/stopwatch.css b/docs/examples/introduction/stopwatch.css index 472dccc89..c7e0fbb92 100644 --- a/docs/examples/introduction/stopwatch.css +++ b/docs/examples/introduction/stopwatch.css @@ -52,3 +52,6 @@ Button { visibility: hidden } +/* #timers:hover { + background: blue; +} */ diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index 15dff3a1c..ba31a7e26 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -88,6 +88,7 @@ class StopwatchApp(App): new_stopwatch = Stopwatch() self.query_one("#timers").mount(new_stopwatch) new_stopwatch.scroll_visible() + self.sub_title = str(len(self.query("Stopwatch"))) def action_remove_stopwatch(self) -> None: """Called to remove a timer.""" diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 503706a3a..8d5c299b7 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -487,9 +487,8 @@ class Compositor: """Get the widget under the given point or None.""" # TODO: Optimize with some line based lookup contains = Region.contains - is_visible = self.visible_widgets.__contains__ for widget, cropped_region, region, *_ in self: - if is_visible(widget) and contains(cropped_region, x, y): + if contains(cropped_region, x, y): return widget, region raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})") diff --git a/src/textual/app.py b/src/textual/app.py index b1a99b0ff..0be12cd51 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() @@ -720,15 +720,13 @@ class App(Generic[ReturnType], DOMNode): Should be called whenever CSS classes / pseudo classes change. """ - if node is None: - self._require_stylesheet_update = True - self.check_idle() - else: - self.stylesheet.update(node, animate=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.stylesheet.update_nodes(self.screen.visible_widgets) + self._require_stylesheet_update.update(self.screen.visible_widgets) + self.check_idle() def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: """Mount widgets. Widgets specified as positional args, or keywords args. If supplied @@ -1144,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 7c3440ece..c5efebc15 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -160,11 +160,13 @@ class RuleSet: styles: Styles = field(default_factory=Styles) errors: list[tuple[Token, str]] = field(default_factory=list) - ids: 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: tokens: list[str] = [] @@ -200,10 +202,22 @@ class RuleSet: 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_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 diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 564ec9deb..c33600ea4 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -312,7 +312,13 @@ class Stylesheet: 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: @@ -334,12 +340,18 @@ class Stylesheet: _check_rule = self._check_rule css_path_nodes = node.css_path_nodes + selector_names = { selector for node in css_path_nodes for selector in node._selector_names } + 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: if rule.selector_names and not rule.selector_names.issubset(selector_names): continue @@ -457,10 +469,17 @@ class Stylesheet: nodes (DOMNode): Nodes to update. animate (bool, optional): Enable CSS animation. Defaults to False. """ + + rules_map: defaultdict[str, list[RuleSet]] = defaultdict(list) + for rule in self.rules: + for name in rule.selector_names: + rules_map[name].append(rule) + apply = self.apply - nodes = list(nodes) + for node in nodes: - apply(node, animate=animate) + rules = {rule for name in node._selector_names 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/dom.py b/src/textual/dom.py index 1218820fd..babc62f2b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -320,6 +320,7 @@ class DOMNode(MessagePump): 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, @@ -716,7 +717,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass @@ -732,7 +733,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass @@ -748,7 +749,7 @@ class DOMNode(MessagePump): if old_classes == self._classes: return try: - self.app.stylesheet.update(self, animate=True) + self.app.update_styles(self) except NoActiveAppError: pass From 6e01d46b40b432c70ea4ee6cf402355adcb36538 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 10:13:49 +0100 Subject: [PATCH 11/13] remove debug --- docs/examples/introduction/stopwatch.css | 4 ---- docs/examples/introduction/stopwatch.py | 1 - 2 files changed, 5 deletions(-) diff --git a/docs/examples/introduction/stopwatch.css b/docs/examples/introduction/stopwatch.css index c7e0fbb92..93678369c 100644 --- a/docs/examples/introduction/stopwatch.css +++ b/docs/examples/introduction/stopwatch.css @@ -51,7 +51,3 @@ Button { .started #reset { visibility: hidden } - -/* #timers:hover { - background: blue; -} */ diff --git a/docs/examples/introduction/stopwatch.py b/docs/examples/introduction/stopwatch.py index ba31a7e26..15dff3a1c 100644 --- a/docs/examples/introduction/stopwatch.py +++ b/docs/examples/introduction/stopwatch.py @@ -88,7 +88,6 @@ class StopwatchApp(App): new_stopwatch = Stopwatch() self.query_one("#timers").mount(new_stopwatch) new_stopwatch.scroll_visible() - self.sub_title = str(len(self.query("Stopwatch"))) def action_remove_stopwatch(self) -> None: """Called to remove a timer.""" From f794c85b2fcfeb16c6451fa8fa0df5e9375632d6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 12:44:29 +0100 Subject: [PATCH 12/13] precalculate rules_map --- src/textual/css/stylesheet.py | 50 +++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index c33600ea4..b705d0f50 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,19 +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 - -from .._profile import timer class StylesheetParseError(StylesheetError): @@ -137,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 @@ -146,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) @@ -285,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. @@ -302,6 +323,7 @@ class Stylesheet: ) stylesheet.parse() self._rules = stylesheet.rules + self._rules_map = None self.source = stylesheet.source @classmethod @@ -470,15 +492,15 @@ class Stylesheet: animate (bool, optional): Enable CSS animation. Defaults to False. """ - rules_map: defaultdict[str, list[RuleSet]] = defaultdict(list) - for rule in self.rules: - for name in rule.selector_names: - rules_map[name].append(rule) - + rules_map = self.rules_map apply = self.apply for node in nodes: - rules = {rule for name in node._selector_names for rule in rules_map[name]} + rules = { + rule + for name in node._selector_names + for rule in rules_map.get(name, []) + } apply(node, limit_rules=rules, animate=animate) if isinstance(node, Widget) and node.is_scrollable: if node.show_vertical_scrollbar: From 46ee74848cbed486b82075296e3d4f8de4463c82 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Aug 2022 12:52:07 +0100 Subject: [PATCH 13/13] simplify --- src/textual/css/stylesheet.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index b705d0f50..849688c42 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -360,13 +360,8 @@ class Stylesheet: rule_attributes = defaultdict(list) _check_rule = self._check_rule - css_path_nodes = node.css_path_nodes - selector_names = { - selector for node in css_path_nodes for selector in node._selector_names - } - rules: Iterable[RuleSet] if limit_rules: rules = [rule for rule in reversed(self.rules) if rule in limit_rules] @@ -374,9 +369,6 @@ class Stylesheet: rules = reversed(self.rules) # Collect the rules defined in the stylesheet for rule in rules: - if rule.selector_names and not rule.selector_names.issubset(selector_names): - continue - is_default_rules = rule.is_default_rules tie_breaker = rule.tie_breaker for base_specificity in _check_rule(rule, css_path_nodes): @@ -499,7 +491,8 @@ class Stylesheet: rules = { rule for name in node._selector_names - for rule in rules_map.get(name, []) + 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: