From 5eea51ce1bfad433b0711c913faff41ed8e005f0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 9 Dec 2023 11:17:59 +0000 Subject: [PATCH] Optimizations (#3837) * optimize layout * optimization * test fix * startup optimization * simplify * faster content height --- src/textual/_callback.py | 15 +++++++++++++-- src/textual/app.py | 5 ++--- src/textual/css/_style_properties.py | 2 +- src/textual/dom.py | 28 +++++++++++++++++----------- src/textual/layouts/horizontal.py | 22 ++++++++++++++++------ src/textual/layouts/vertical.py | 24 ++++++++++++++++-------- src/textual/widget.py | 20 ++++++++++++++++---- src/textual/widgets/_collapsible.py | 2 +- tests/test_arrange.py | 5 +++++ 9 files changed, 87 insertions(+), 36 deletions(-) diff --git a/src/textual/_callback.py b/src/textual/_callback.py index abefeae557..756a9352af 100644 --- a/src/textual/_callback.py +++ b/src/textual/_callback.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from functools import lru_cache +from functools import lru_cache, partial from inspect import isawaitable, signature from typing import TYPE_CHECKING, Any, Callable @@ -14,8 +14,19 @@ INVOKE_TIMEOUT_WARNING = 3 -@lru_cache(maxsize=2048) def count_parameters(func: Callable) -> int: + """Count the number of parameters in a callable""" + if isinstance(func, partial): + return _count_parameters(func.func) + len(func.args) + if hasattr(func, "__self__"): + # Bound method + func = func.__func__ # type: ignore + return _count_parameters(func) - 1 + return _count_parameters(func) + + +@lru_cache(maxsize=2048) +def _count_parameters(func: Callable) -> int: """Count the number of parameters in a callable""" return len(signature(func).parameters) diff --git a/src/textual/app.py b/src/textual/app.py index e4292fffdf..7309841dd6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -829,8 +829,8 @@ def watch_dark(self, dark: bool) -> None: This method handles the transition between light and dark mode when you change the [dark][textual.app.App.dark] attribute. """ - self.set_class(dark, "-dark-mode") - self.set_class(not dark, "-light-mode") + self.set_class(dark, "-dark-mode", update=False) + self.set_class(not dark, "-light-mode", update=False) self.call_later(self.refresh_css) def get_driver_class(self) -> Type[Driver]: @@ -1586,7 +1586,6 @@ def update_styles(self, node: DOMNode) -> None: will be added, and this method is called to apply the corresponding :hover styles. """ - descendants = node.walk_children(with_self=True) self.stylesheet.update_nodes(descendants, animate=True) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 51655d26ca..efcc309930 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -761,7 +761,7 @@ def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> s Returns: The string property value. """ - return cast(str, obj.get_rule(self.name, self._default)) + return obj.get_rule(self.name, self._default) # type: ignore def _before_refresh(self, obj: StylesBase, value: str | None) -> None: """Do any housekeeping before asking for a layout refresh after a value change.""" diff --git a/src/textual/dom.py b/src/textual/dom.py index 6e35e993ec..e96578b17b 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1176,19 +1176,20 @@ def has_class(self, *class_names: str) -> bool: """ return self._classes.issuperset(class_names) - def set_class(self, add: bool, *class_names: str) -> Self: + def set_class(self, add: bool, *class_names: str, update: bool = True) -> Self: """Add or remove class(es) based on a condition. Args: add: Add the classes if True, otherwise remove them. + update: Also update styles. Returns: Self. """ if add: - self.add_class(*class_names) + self.add_class(*class_names, update=update) else: - self.remove_class(*class_names) + self.remove_class(*class_names, update=update) return self def set_classes(self, classes: str | Iterable[str]) -> Self: @@ -1209,16 +1210,18 @@ def _update_styles(self) -> None: Should be called whenever CSS classes / pseudo classes change. """ - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + if self._is_mounted: + try: + self.app.update_styles(self) + except NoActiveAppError: + pass - def add_class(self, *class_names: str) -> Self: + def add_class(self, *class_names: str, update: bool = True) -> Self: """Add class names to this Node. Args: *class_names: CSS class names to add. + update: Also update styles. Returns: Self. @@ -1228,14 +1231,16 @@ def add_class(self, *class_names: str) -> Self: self._classes.update(class_names) if old_classes == self._classes: return self - self._update_styles() + if update: + self._update_styles() return self - def remove_class(self, *class_names: str) -> Self: + def remove_class(self, *class_names: str, update: bool = True) -> Self: """Remove class names from this Node. Args: *class_names: CSS class names to remove. + update: Also update styles. Returns: Self. @@ -1245,7 +1250,8 @@ def remove_class(self, *class_names: str) -> Self: self._classes.difference_update(class_names) if old_classes == self._classes: return self - self._update_styles() + if update: + self._update_styles() return self def toggle_class(self, *class_names: str) -> Self: diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 51d00884f7..9d5697f0fe 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -73,16 +73,26 @@ def arrange( _Region = Region _WidgetPlacement = WidgetPlacement - for widget, box_model, margin in zip(children, box_models, margins): + for widget, (content_width, content_height, box_margin), margin in zip( + children, box_models, margins + ): overlay = widget.styles.overlay == "screen" - content_width, content_height, box_margin = box_model offset_y = box_margin.top next_x = x + content_width - region = _Region( - int(x), offset_y, int(next_x - int(x)), int(content_height) - ) add_placement( - _WidgetPlacement(region, box_model.margin, widget, 0, False, overlay) + _WidgetPlacement( + _Region( + int(x), + offset_y, + int(next_x - int(x)), + int(content_height), + ), + box_margin, + widget, + 0, + False, + overlay, + ) ) if not overlay: x = next_x + margin diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 539b373dce..a81518f1d2 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -73,17 +73,25 @@ def arrange( _Region = Region _WidgetPlacement = WidgetPlacement - - for widget, box_model, margin in zip(children, box_models, margins): + for widget, (content_width, content_height, box_margin), margin in zip( + children, box_models, margins + ): overlay = widget.styles.overlay == "screen" - content_width, content_height, box_margin = box_model next_y = y + content_height - - region = _Region( - box_margin.left, int(y), int(content_width), int(next_y) - int(y) - ) add_placement( - _WidgetPlacement(region, box_model.margin, widget, 0, False, overlay) + _WidgetPlacement( + _Region( + box_margin.left, + int(y), + int(content_width), + int(next_y) - int(y), + ), + box_margin, + widget, + 0, + False, + overlay, + ) ) if not overlay: y = next_y + margin diff --git a/src/textual/widget.py b/src/textual/widget.py index 3ce45f72b4..dbcbf0e5f5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1180,10 +1180,22 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int return self._content_height_cache[1] renderable = self.render() - options = self._console.options.update_width(width).update(highlight=False) - segments = self._console.render(renderable, options) - # Cheaper than counting the lines returned from render_lines! - height = sum([text.count("\n") for text, _, _ in segments]) + if isinstance(renderable, Text): + height = len( + renderable.wrap( + self._console, + width, + no_wrap=renderable.no_wrap, + tab_size=renderable.tab_size or 8, + ) + ) + else: + options = self._console.options.update_width(width).update( + highlight=False + ) + segments = self._console.render(renderable, options) + # Cheaper than counting the lines returned from render_lines! + height = sum([text.count("\n") for text, _, _ in segments]) self._content_height_cache = (cache_key, height) return height diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index dcd9441e40..ba3565f20a 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -199,7 +199,7 @@ def _update_collapsed(self, collapsed: bool) -> None: except NoMatches: pass - def _on_mount(self) -> None: + def _on_mount(self, event: events.Mount) -> None: """Initialise collapsed state.""" self._update_collapsed(self.collapsed) diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 38f5a191d0..ffedb6d787 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -2,6 +2,7 @@ from textual._arrange import TOP_Z, arrange from textual._layout import WidgetPlacement +from textual.app import App from textual.geometry import Region, Size, Spacing from textual.widget import Widget @@ -16,6 +17,7 @@ def test_arrange_empty(): def test_arrange_dock_top(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "top" @@ -34,6 +36,7 @@ def test_arrange_dock_top(): def test_arrange_dock_left(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "left" @@ -51,6 +54,7 @@ def test_arrange_dock_left(): def test_arrange_dock_right(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "right" @@ -68,6 +72,7 @@ def test_arrange_dock_right(): def test_arrange_dock_bottom(): container = Widget(id="container") + container._parent = App() child = Widget(id="child") header = Widget(id="header") header.styles.dock = "bottom"