From eee262d08a13f47261c8f55c9acc9fdca2c441ee Mon Sep 17 00:00:00 2001 From: fancidev Date: Sat, 2 Aug 2025 15:55:53 +0800 Subject: [PATCH 1/5] Have Styles and RenderStyles keep a weak reference to the node. --- src/textual/css/styles.py | 28 ++++++++++++++++++++++++---- src/textual/css/stylesheet.py | 1 + src/textual/dom.py | 1 + 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index d306d42768..12186f3b3a 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -4,6 +4,7 @@ from functools import partial from operator import attrgetter from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Literal, cast +from weakref import ReferenceType, ref import rich.repr from rich.style import Style @@ -860,20 +861,29 @@ def partial_rich_style(self) -> Style: @rich.repr.auto @dataclass class Styles(StylesBase): - node: DOMNode | None = None + _node_ref: ReferenceType[DOMNode] | None = None _rules: RulesMap = field(default_factory=RulesMap) _updates: int = 0 important: set[str] = field(default_factory=set) def __post_init__(self) -> None: + self._node_ref = ref(self._node_ref) if self._node_ref is not None else None self.get_rule: Callable[[str, object], object] = self._rules.get # type: ignore[assignment] self.has_rule: Callable[[str], bool] = self._rules.__contains__ # type: ignore[assignment] + @property + def node(self) -> DOMNode | None: + return self._node_ref() if self._node_ref is not None else None + + @node.setter + def node(self, node: DOMNode | None) -> None: + self._node_ref = ref(node) if node is not None else None + def copy(self) -> Styles: """Get a copy of this Styles object.""" return Styles( - node=self.node, + _node=self.node, _rules=self.get_rules(), important=self.important, ) @@ -1308,8 +1318,10 @@ def css(self) -> str: class RenderStyles(StylesBase): """Presents a combined view of two Styles object: a base Styles and inline Styles.""" - def __init__(self, node: DOMNode, base: Styles, inline_styles: Styles) -> None: - self.node = node + def __init__( + self, node: DOMNode | None, base: Styles, inline_styles: Styles + ) -> None: + self._node_ref: ReferenceType[DOMNode] = ref(node) if node is not None else None self._base_styles = base self._inline_styles = inline_styles self._animate: BoundAnimator | None = None @@ -1334,6 +1346,14 @@ def _cache_key(self) -> int: """ return self._updates + self._base_styles._updates + self._inline_styles._updates + @property + def node(self) -> DOMNode | None: + return self._node_ref() if self._node_ref is not None else None + + @node.setter + def node(self, node: DOMNode) -> None: + self._node_ref = ref(node) if node is not None else None + @property def base(self) -> Styles: """Quick access to base (css) style.""" diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 7d97cda9f0..91a466a7bf 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -621,6 +621,7 @@ def _process_component_classes(self, node: DOMNode) -> None: for component in sorted(component_classes): virtual_node = DOMNode(classes=component) virtual_node._attach(node) + node._virtual_nodes.append(virtual_node) # keep alive self.apply(virtual_node, animate=False) if ( not refresh_node diff --git a/src/textual/dom.py b/src/textual/dom.py index 2dba90b015..168cccdb6e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -201,6 +201,7 @@ def __init__( check_identifiers("class name", *_classes) self._classes.update(_classes) + self._virtual_nodes: List[DOMNode] = [] self._nodes: NodeList = NodeList(self) self._css_styles: Styles = Styles(self) self._inline_styles: Styles = Styles(self) From c5a1475f658655f055ae3653592c6d355e59859b Mon Sep 17 00:00:00 2001 From: fancidev Date: Sat, 2 Aug 2025 18:39:30 +0800 Subject: [PATCH 2/5] Fix Styles.copy(). --- src/textual/css/styles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 12186f3b3a..c3cb845089 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -883,7 +883,7 @@ def node(self, node: DOMNode | None) -> None: def copy(self) -> Styles: """Get a copy of this Styles object.""" return Styles( - _node=self.node, + _node_ref=self.node, _rules=self.get_rules(), important=self.important, ) From 30423e28ff18d838a5d3a4d3b2630e8b3adba7a2 Mon Sep 17 00:00:00 2001 From: fancidev Date: Sun, 3 Aug 2025 07:42:58 +0800 Subject: [PATCH 3/5] Remove virtual node when the corresponding RenderStyles is collected. --- src/textual/css/stylesheet.py | 8 +++++++- src/textual/dom.py | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 91a466a7bf..87d1a0e2e6 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import weakref from collections import defaultdict from itertools import chain from operator import itemgetter @@ -621,7 +622,6 @@ def _process_component_classes(self, node: DOMNode) -> None: for component in sorted(component_classes): virtual_node = DOMNode(classes=component) virtual_node._attach(node) - node._virtual_nodes.append(virtual_node) # keep alive self.apply(virtual_node, animate=False) if ( not refresh_node @@ -630,6 +630,12 @@ def _process_component_classes(self, node: DOMNode) -> None: # If the styles have changed we want to refresh the node refresh_node = True node._component_styles[component] = virtual_node.styles + node._component_styles_nodes.append(virtual_node) + weakref.finalize( + virtual_node.styles, + node._component_styles_nodes.remove, + virtual_node, + ) if refresh_node: node.refresh() diff --git a/src/textual/dom.py b/src/textual/dom.py index 168cccdb6e..fb0f0a7c71 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -201,7 +201,6 @@ def __init__( check_identifiers("class name", *_classes) self._classes.update(_classes) - self._virtual_nodes: List[DOMNode] = [] self._nodes: NodeList = NodeList(self) self._css_styles: Styles = Styles(self) self._inline_styles: Styles = Styles(self) @@ -210,6 +209,10 @@ def __init__( ) # A mapping of class names to Styles set in COMPONENT_CLASSES self._component_styles: dict[str, RenderStyles] = {} + # Hold strong references to the virtual nodes created for the RenderStyles objects + # in COMPONENT_CLASSES. A virtual node is removed when the corresponding RenderStyles + # object is garbage collected, via weakref.finalize(). + self._component_styles_nodes: list[DOMNode] = [] self._auto_refresh: float | None = None self._auto_refresh_timer: Timer | None = None From ba95d62ed8927b2f12a2bafdb744928a90593b9b Mon Sep 17 00:00:00 2001 From: fancidev Date: Sun, 3 Aug 2025 15:16:01 +0800 Subject: [PATCH 4/5] Hold reference to component styles nodes instead of component styles. --- src/textual/css/stylesheet.py | 20 +++++++------------- src/textual/dom.py | 13 +++++-------- src/textual/widget.py | 2 +- 3 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 87d1a0e2e6..9af0623147 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import weakref from collections import defaultdict from itertools import chain from operator import itemgetter @@ -617,25 +616,20 @@ def _process_component_classes(self, node: DOMNode) -> None: if component_classes: # Create virtual nodes that exist to extract styles refresh_node = False - old_component_styles = node._component_styles.copy() - node._component_styles.clear() + old_component_styles_nodes = node._component_styles_nodes.copy() + node._component_styles_nodes.clear() for component in sorted(component_classes): virtual_node = DOMNode(classes=component) virtual_node._attach(node) self.apply(virtual_node, animate=False) - if ( - not refresh_node - and old_component_styles.get(component) != virtual_node.styles + if not refresh_node and ( + component not in old_component_styles_nodes + or old_component_styles_nodes[component].styles + != virtual_node.styles ): # If the styles have changed we want to refresh the node refresh_node = True - node._component_styles[component] = virtual_node.styles - node._component_styles_nodes.append(virtual_node) - weakref.finalize( - virtual_node.styles, - node._component_styles_nodes.remove, - virtual_node, - ) + node._component_styles_nodes[component] = virtual_node if refresh_node: node.refresh() diff --git a/src/textual/dom.py b/src/textual/dom.py index fb0f0a7c71..f5f474b8b9 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -207,12 +207,9 @@ def __init__( self.styles: RenderStyles = RenderStyles( self, self._css_styles, self._inline_styles ) - # A mapping of class names to Styles set in COMPONENT_CLASSES - self._component_styles: dict[str, RenderStyles] = {} - # Hold strong references to the virtual nodes created for the RenderStyles objects - # in COMPONENT_CLASSES. A virtual node is removed when the corresponding RenderStyles - # object is garbage collected, via weakref.finalize(). - self._component_styles_nodes: list[DOMNode] = [] + # A mapping of class names to virtual nodes whose 'styles' attribute + # corresponds to Styles set in COMPONENT_CLASSES + self._component_styles_nodes: dict[str, DOMNode] = {} self._auto_refresh: float | None = None self._auto_refresh_timer: Timer | None = None @@ -583,9 +580,9 @@ def get_component_styles(self, *names: str) -> RenderStyles: styles = RenderStyles(self, Styles(), Styles()) for name in names: - if name not in self._component_styles: + if name not in self._component_styles_nodes: raise KeyError(f"No {name!r} key in COMPONENT_CLASSES") - component_styles = self._component_styles[name] + component_styles = self._component_styles_nodes[name].styles styles.node = component_styles.node styles.base.merge(component_styles.base) styles.inline.merge(component_styles.inline) diff --git a/src/textual/widget.py b/src/textual/widget.py index 8543706dad..3fcef31098 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4338,7 +4338,7 @@ async def _message_loop_exit(self) -> None: self._arrangement_cache.clear() self._nodes._clear() self._render_cache = _RenderCache(NULL_SIZE, []) - self._component_styles.clear() + self._component_styles_nodes.clear() self._query_one_cache.clear() async def _on_idle(self, event: events.Idle) -> None: From 428bcec742884fe50b08a6916bef6e42f214e26a Mon Sep 17 00:00:00 2001 From: fancidev Date: Sun, 3 Aug 2025 15:39:34 +0800 Subject: [PATCH 5/5] Initialize Styles explicitly rather than using dataclass. --- src/textual/css/styles.py | 43 ++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index c3cb845089..024e92184e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1,6 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass, field from functools import partial from operator import attrgetter from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Literal, cast @@ -859,34 +858,35 @@ def partial_rich_style(self) -> Style: @rich.repr.auto -@dataclass class Styles(StylesBase): - _node_ref: ReferenceType[DOMNode] | None = None - _rules: RulesMap = field(default_factory=RulesMap) - _updates: int = 0 - - important: set[str] = field(default_factory=set) - - def __post_init__(self) -> None: - self._node_ref = ref(self._node_ref) if self._node_ref is not None else None - self.get_rule: Callable[[str, object], object] = self._rules.get # type: ignore[assignment] - self.has_rule: Callable[[str], bool] = self._rules.__contains__ # type: ignore[assignment] + def __init__( + self, node: DOMNode | None = None, rules: RulesMap | None = None + ) -> None: + self._node_ref: ReferenceType[DOMNode] | None = ( + ref(node) if node is not None else None + ) + self._rules: RulesMap = rules if rules is not None else RulesMap() + self._updates: int = 0 + self.important: set[str] = set() + self.get_rule: Callable[[str, object], object] = self._rules.get + self.has_rule: Callable[[str], bool] = self._rules.__contains__ @property def node(self) -> DOMNode | None: + """Get the associated node. If there is no associated node or the associated + node has been garbage collected, return None.""" return self._node_ref() if self._node_ref is not None else None @node.setter def node(self, node: DOMNode | None) -> None: + """Set the associated node. A weak reference to the node is stored.""" self._node_ref = ref(node) if node is not None else None def copy(self) -> Styles: """Get a copy of this Styles object.""" - return Styles( - _node_ref=self.node, - _rules=self.get_rules(), - important=self.important, - ) + other = Styles(self.node, self.get_rules()) + other.important = self.important + return other def clear_rule(self, rule_name: str) -> bool: """Removes the rule from the Styles object, as if it had never been set. @@ -1321,7 +1321,9 @@ class RenderStyles(StylesBase): def __init__( self, node: DOMNode | None, base: Styles, inline_styles: Styles ) -> None: - self._node_ref: ReferenceType[DOMNode] = ref(node) if node is not None else None + self._node_ref: ReferenceType[DOMNode] | None = ( + ref(node) if node is not None else None + ) self._base_styles = base self._inline_styles = inline_styles self._animate: BoundAnimator | None = None @@ -1348,10 +1350,13 @@ def _cache_key(self) -> int: @property def node(self) -> DOMNode | None: + """Get the associated node. If there is no associated node or the associated + node has been garbage collected, return None.""" return self._node_ref() if self._node_ref is not None else None @node.setter - def node(self, node: DOMNode) -> None: + def node(self, node: DOMNode | None) -> None: + """Set the associated node. A weak reference to the node is stored.""" self._node_ref = ref(node) if node is not None else None @property