mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
lazy mount (#3936)
* lazy mount * Lazy test * doc * Add to docs * snapshot and changelog * typing * future * less flaky * comment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
65
src/textual/lazy.py
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
26
tests/test_lazy.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user