Skip to content

Commit

Permalink
rules caching (#3880)
Browse files Browse the repository at this point in the history
* rules caching

* changelog

* docstring

* fix cache

* fix test

* fix test

* remove comment [skip ci]
  • Loading branch information
willmcgugan committed Dec 15, 2023
1 parent 1ebb59b commit b4a5674
Show file tree
Hide file tree
Showing 4 changed files with 45 additions and 8 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Disabled radio buttons could be selected with the keyboard https://github.com/Textualize/textual/issues/3839

## Added

- Added caching of rules attributes and `cache` parameter to Stylesheet.apply https://github.com/Textualize/textual/pull/3880

## [0.45.1] - 2023-12-12

Expand Down
10 changes: 7 additions & 3 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
from .binding import Binding, BindingType, _Bindings
from .command import CommandPalette, Provider
from .css.query import NoMatches
from .css.stylesheet import Stylesheet
from .css.stylesheet import RulesMap, Stylesheet
from .design import ColorSystem
from .dom import DOMNode
from .driver import Driver
Expand Down Expand Up @@ -2392,6 +2392,7 @@ def _register(
*widgets: Widget,
before: int | None = None,
after: int | None = None,
cache: dict[tuple, RulesMap] | None = None,
) -> list[Widget]:
"""Register widget(s) so they may receive events.
Expand All @@ -2400,6 +2401,7 @@ def _register(
*widgets: The widget(s) to register.
before: A location to mount before.
after: A location to mount after.
cache: Optional rules map cache.
Returns:
List of modified widgets.
Expand All @@ -2408,6 +2410,8 @@ def _register(
if not widgets:
return []

if cache is None:
cache = {}
widget_list: Iterable[Widget]
if before is not None or after is not None:
# There's a before or after, which means there's going to be an
Expand All @@ -2424,8 +2428,8 @@ def _register(
if widget not in self._registry:
self._register_child(parent, widget, before, after)
if widget._nodes:
self._register(widget, *widget._nodes)
apply_stylesheet(widget)
self._register(widget, *widget._nodes, cache=cache)
apply_stylesheet(widget, cache=cache)

if not self._running:
# If the app is not running, prevent awaiting of the widget tasks
Expand Down
38 changes: 34 additions & 4 deletions src/textual/css/stylesheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ def apply(
node: DOMNode,
*,
animate: bool = False,
cache: dict[tuple, RulesMap] | None = None,
) -> None:
"""Apply the stylesheet to a DOM node.
Expand All @@ -428,6 +429,7 @@ def apply(
classes modifying the same CSS property), then only the most specific
rule will be applied.
animate: Animate changed rules.
cache: An optional cache when applying a group of nodes.
"""
# 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.
Expand All @@ -437,6 +439,23 @@ def apply(
rule_attributes: defaultdict[str, list[tuple[Specificity6, object]]]
rule_attributes = defaultdict(list)

cache_key: tuple | None
if cache is not None:
cache_key = (
node._parent,
node._id,
node.classes,
node.pseudo_classes,
node._css_type_name,
)
cached_result: RulesMap | None = cache.get(cache_key)
if cached_result is not None:
self.replace_rules(node, cached_result, animate=animate)
self._process_component_classes(node)
return
else:
cache_key = None

_check_rule = self._check_rule
css_path_nodes = node.css_path_nodes

Expand Down Expand Up @@ -520,8 +539,18 @@ def apply(
rule_value = getattr(_DEFAULT_STYLES, initial_rule_name)
node_rules[initial_rule_name] = rule_value # type: ignore[literal-required]

if cache is not None:
assert cache_key is not None
cache[cache_key] = node_rules
self.replace_rules(node, node_rules, animate=animate)
self._process_component_classes(node)

def _process_component_classes(self, node: DOMNode) -> None:
"""Process component classes for the given node.
Args:
node: A DOM Node.
"""
component_classes = node._get_component_classes()
if component_classes:
# Create virtual nodes that exist to extract styles
Expand Down Expand Up @@ -628,14 +657,15 @@ def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None:
nodes: Nodes to update.
animate: Enable CSS animation.
"""
cache: dict[tuple, RulesMap] = {}
apply = self.apply

for node in nodes:
apply(node, animate=animate)
apply(node, animate=animate, cache=cache)
if isinstance(node, Widget) and node.is_scrollable:
if node.show_vertical_scrollbar:
apply(node.vertical_scrollbar)
apply(node.vertical_scrollbar, cache=cache)
if node.show_horizontal_scrollbar:
apply(node.horizontal_scrollbar)
apply(node.horizontal_scrollbar, cache=cache)
if node.show_horizontal_scrollbar and node.show_vertical_scrollbar:
apply(node.scrollbar_corner)
apply(node.scrollbar_corner, cache=cache)
2 changes: 1 addition & 1 deletion src/textual/widgets/_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ def animate_underline() -> None:
underline.animate("highlight_start", start, duration=0.3)
underline.animate("highlight_end", end, duration=0.3)

self.set_timer(0.01, lambda: self.call_after_refresh(animate_underline))
self.set_timer(0.05, animate_underline)
else:
underline.highlight_start = start
underline.highlight_end = end
Expand Down

0 comments on commit b4a5674

Please sign in to comment.