lazy mount (#3936)

* lazy mount

* Lazy test

* doc

* Add to docs

* snapshot and changelog

* typing

* future

* less flaky

* comment
This commit is contained in:
Will McGugan
2024-01-01 15:54:55 +00:00
committed by GitHub
parent 244e42c6fc
commit b8fccd494a
10 changed files with 209 additions and 79 deletions

View File

@@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Breaking change: `Widget.move_child` parameters `before` and `after` are now keyword-only https://github.com/Textualize/textual/pull/3896
### Added
- Added textual.lazy https://github.com/Textualize/textual/pull/3936
## [0.46.0] - 2023-12-17
### Fixed

View File

@@ -186,6 +186,7 @@ nav:
- "api/filter.md"
- "api/fuzzy_matcher.md"
- "api/geometry.md"
- "api/lazy.md"
- "api/logger.md"
- "api/logging.md"
- "api/map_geometry.md"

View File

@@ -997,6 +997,9 @@ class DOMNode(MessagePump):
def _add_child(self, node: Widget) -> None:
"""Add a new child node.
!!! note
For tests only.
Args:
node: A DOM node.
"""
@@ -1006,6 +1009,9 @@ class DOMNode(MessagePump):
def _add_children(self, *nodes: Widget) -> None:
"""Add multiple children to this node.
!!! note
For tests only.
Args:
*nodes: Positional args should be new DOM nodes.
"""
@@ -1013,6 +1019,7 @@ class DOMNode(MessagePump):
for node in nodes:
node._attach(self)
_append(node)
node._add_children(*node._pending_children)
WalkType = TypeVar("WalkType", bound="DOMNode")

View File

@@ -1152,5 +1152,8 @@ NULL_OFFSET: Final = Offset(0, 0)
NULL_REGION: Final = Region(0, 0, 0, 0)
"""A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero)."""
NULL_SIZE: Final = Size(0, 0)
"""A [Size][textual.geometry.Size] constant for a null size (with zero area)."""
NULL_SPACING: Final = Spacing(0, 0, 0, 0)
"""A [Spacing][textual.geometry.Spacing] constant for no space."""

65
src/textual/lazy.py Normal file
View File

@@ -0,0 +1,65 @@
"""
Tools for lazy loading widgets.
"""
from __future__ import annotations
from .widget import Widget
class Lazy(Widget):
"""Wraps a widget so that it is mounted *lazily*.
Lazy widgets are mounted after the first refresh. This can be used to display some parts of
the UI very quickly, followed by the lazy widgets. Technically, this won't make anything
faster, but it reduces the time the user sees a blank screen and will make apps feel
more responsive.
Making a widget lazy is beneficial for widgets which start out invisible, such as tab panes.
Note that since lazy widgets aren't mounted immediately (by definition), they will not appear
in queries for a brief interval until they are mounted. Your code should take this in to account.
Example:
```python
def compose(self) -> ComposeResult:
yield Footer()
with ColorTabs("Theme Colors", "Named Colors"):
yield Content(ThemeColorButtons(), ThemeColorsView(), id="theme")
yield Lazy(NamedColorsView())
```
"""
DEFAULT_CSS = """
Lazy {
display: none;
}
"""
def __init__(self, widget: Widget) -> None:
"""Create a lazy widget.
Args:
widget: A widget that should be mounted after a refresh.
"""
self._replace_widget = widget
super().__init__()
def compose_add_child(self, widget: Widget) -> None:
self._replace_widget.compose_add_child(widget)
async def mount_composed_widgets(self, widgets: list[Widget]) -> None:
parent = self.parent
if parent is None:
return
assert isinstance(parent, Widget)
async def mount() -> None:
"""Perform the mount and discard the lazy widget."""
await parent.mount(self._replace_widget, after=self)
await self.remove()
self.call_after_refresh(mount)

View File

@@ -55,7 +55,16 @@ from .box_model import BoxModel
from .css.query import NoMatches, WrongType
from .css.scalar import ScalarOffset
from .dom import DOMNode, NoScreen
from .geometry import NULL_REGION, NULL_SPACING, Offset, Region, Size, Spacing, clamp
from .geometry import (
NULL_REGION,
NULL_SIZE,
NULL_SPACING,
Offset,
Region,
Size,
Spacing,
clamp,
)
from .layouts.vertical import VerticalLayout
from .message import Message
from .messages import CallbackType
@@ -300,8 +309,9 @@ class Widget(DOMNode):
classes: The CSS classes for the widget.
disabled: Whether the widget is disabled or not.
"""
self._size = Size(0, 0)
self._container_size = Size(0, 0)
_null_size = NULL_SIZE
self._size = _null_size
self._container_size = _null_size
self._layout_required = False
self._repaint_required = False
self._scroll_required = False
@@ -316,7 +326,7 @@ class Widget(DOMNode):
self._border_title: Text | None = None
self._border_subtitle: Text | None = None
self._render_cache = _RenderCache(Size(0, 0), [])
self._render_cache = _RenderCache(_null_size, [])
# Regions which need to be updated (in Widget)
self._dirty_regions: set[Region] = set()
# Regions which need to be transferred from cache to screen
@@ -355,8 +365,7 @@ class Widget(DOMNode):
raise TypeError(
f"Widget positional arguments must be Widget subclasses; not {child!r}"
)
self._add_children(*children)
self._pending_children = list(children)
self.disabled = disabled
if self.BORDER_TITLE:
self.border_title = self.BORDER_TITLE
@@ -511,7 +520,7 @@ class Widget(DOMNode):
widget: A Widget to add.
"""
_rich_traceback_omit = True
self._nodes._append(widget)
self._pending_children.append(widget)
def __enter__(self) -> Self:
"""Use as context manager when composing."""
@@ -2974,7 +2983,7 @@ class Widget(DOMNode):
and self in self.app.focused.ancestors_with_self
):
self.app.focused.blur()
except ScreenStackError:
except (ScreenStackError, NoActiveAppError):
pass
self._update_styles()
@@ -3401,9 +3410,11 @@ class Widget(DOMNode):
async def handle_key(self, event: events.Key) -> bool:
return await self.dispatch_key(event)
async def _on_compose(self) -> None:
async def _on_compose(self, event: events.Compose) -> None:
event.prevent_default()
try:
widgets = [*self._nodes, *compose(self)]
widgets = [*self._pending_children, *compose(self)]
self._pending_children.clear()
except TypeError as error:
raise TypeError(
f"{self!r} compose() method returned an invalid result; {error}"
@@ -3414,7 +3425,19 @@ class Widget(DOMNode):
self.app.panic(Traceback())
else:
self._extend_compose(widgets)
await self.mount(*widgets)
await self.mount_composed_widgets(widgets)
async def mount_composed_widgets(self, widgets: list[Widget]) -> None:
"""Called by Textual to mount widgets after compose.
There is generally no need to implement this method in your application.
See [Lazy][textual.lazy.Lazy] for a class which uses this method to implement
*lazy* mounting.
Args:
widgets: A list of child widgets.
"""
await self.mount_all(widgets)
def _extend_compose(self, widgets: list[Widget]) -> None:
"""Hook to extend composed widgets.

View File

@@ -121,7 +121,7 @@ class Placeholder(Widget):
while next(self._variants_cycle) != self.variant:
pass
def _on_mount(self) -> None:
async def _on_compose(self, event: events.Compose) -> None:
"""Set the color for this placeholder."""
colors = Placeholder._COLORS.setdefault(
self.app, cycle(_PLACEHOLDER_BACKGROUND_COLORS)

File diff suppressed because one or more lines are too long

26
tests/test_lazy.py Normal file
View File

@@ -0,0 +1,26 @@
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.lazy import Lazy
from textual.widgets import Label
class LazyApp(App):
def compose(self) -> ComposeResult:
with Vertical():
with Lazy(Horizontal()):
yield Label(id="foo")
with Horizontal():
yield Label(id="bar")
async def test_lazy():
app = LazyApp()
async with app.run_test() as pilot:
# No #foo on initial mount
assert len(app.query("#foo")) == 0
assert len(app.query("#bar")) == 1
await pilot.pause()
await pilot.pause()
# #bar mounted after refresh
assert len(app.query("#foo")) == 1
assert len(app.query("#bar")) == 1

View File

@@ -194,7 +194,8 @@ def test_get_pseudo_class_state_disabled():
def test_get_pseudo_class_state_parent_disabled():
child = Widget()
_parent = Widget(child, disabled=True)
_parent = Widget(disabled=True)
child._attach(_parent)
pseudo_classes = child.get_pseudo_class_state()
assert pseudo_classes == PseudoClasses(enabled=False, focus=False, hover=False)