Skip to content

Commit b8fccd4

Browse files
authored
lazy mount (#3936)
* lazy mount * Lazy test * doc * Add to docs * snapshot and changelog * typing * future * less flaky * comment
1 parent 244e42c commit b8fccd4

File tree

10 files changed

+209
-79
lines changed

10 files changed

+209
-79
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1616

1717
- Breaking change: `Widget.move_child` parameters `before` and `after` are now keyword-only https://github.com/Textualize/textual/pull/3896
1818

19+
### Added
20+
21+
- Added textual.lazy https://github.com/Textualize/textual/pull/3936
22+
1923
## [0.46.0] - 2023-12-17
2024

2125
### Fixed

mkdocs-nav.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ nav:
186186
- "api/filter.md"
187187
- "api/fuzzy_matcher.md"
188188
- "api/geometry.md"
189+
- "api/lazy.md"
189190
- "api/logger.md"
190191
- "api/logging.md"
191192
- "api/map_geometry.md"

src/textual/dom.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,9 @@ def reset_styles(self) -> None:
997997
def _add_child(self, node: Widget) -> None:
998998
"""Add a new child node.
999999
1000+
!!! note
1001+
For tests only.
1002+
10001003
Args:
10011004
node: A DOM node.
10021005
"""
@@ -1006,13 +1009,17 @@ def _add_child(self, node: Widget) -> None:
10061009
def _add_children(self, *nodes: Widget) -> None:
10071010
"""Add multiple children to this node.
10081011
1012+
!!! note
1013+
For tests only.
1014+
10091015
Args:
10101016
*nodes: Positional args should be new DOM nodes.
10111017
"""
10121018
_append = self._nodes._append
10131019
for node in nodes:
10141020
node._attach(self)
10151021
_append(node)
1022+
node._add_children(*node._pending_children)
10161023

10171024
WalkType = TypeVar("WalkType", bound="DOMNode")
10181025

src/textual/geometry.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1152,5 +1152,8 @@ def grow_maximum(self, other: Spacing) -> Spacing:
11521152
NULL_REGION: Final = Region(0, 0, 0, 0)
11531153
"""A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero)."""
11541154

1155+
NULL_SIZE: Final = Size(0, 0)
1156+
"""A [Size][textual.geometry.Size] constant for a null size (with zero area)."""
1157+
11551158
NULL_SPACING: Final = Spacing(0, 0, 0, 0)
11561159
"""A [Spacing][textual.geometry.Spacing] constant for no space."""

src/textual/lazy.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Tools for lazy loading widgets.
3+
"""
4+
5+
6+
from __future__ import annotations
7+
8+
from .widget import Widget
9+
10+
11+
class Lazy(Widget):
12+
"""Wraps a widget so that it is mounted *lazily*.
13+
14+
Lazy widgets are mounted after the first refresh. This can be used to display some parts of
15+
the UI very quickly, followed by the lazy widgets. Technically, this won't make anything
16+
faster, but it reduces the time the user sees a blank screen and will make apps feel
17+
more responsive.
18+
19+
Making a widget lazy is beneficial for widgets which start out invisible, such as tab panes.
20+
21+
Note that since lazy widgets aren't mounted immediately (by definition), they will not appear
22+
in queries for a brief interval until they are mounted. Your code should take this in to account.
23+
24+
Example:
25+
26+
```python
27+
def compose(self) -> ComposeResult:
28+
yield Footer()
29+
with ColorTabs("Theme Colors", "Named Colors"):
30+
yield Content(ThemeColorButtons(), ThemeColorsView(), id="theme")
31+
yield Lazy(NamedColorsView())
32+
```
33+
34+
"""
35+
36+
DEFAULT_CSS = """
37+
Lazy {
38+
display: none;
39+
}
40+
"""
41+
42+
def __init__(self, widget: Widget) -> None:
43+
"""Create a lazy widget.
44+
45+
Args:
46+
widget: A widget that should be mounted after a refresh.
47+
"""
48+
self._replace_widget = widget
49+
super().__init__()
50+
51+
def compose_add_child(self, widget: Widget) -> None:
52+
self._replace_widget.compose_add_child(widget)
53+
54+
async def mount_composed_widgets(self, widgets: list[Widget]) -> None:
55+
parent = self.parent
56+
if parent is None:
57+
return
58+
assert isinstance(parent, Widget)
59+
60+
async def mount() -> None:
61+
"""Perform the mount and discard the lazy widget."""
62+
await parent.mount(self._replace_widget, after=self)
63+
await self.remove()
64+
65+
self.call_after_refresh(mount)

src/textual/widget.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,16 @@
5555
from .css.query import NoMatches, WrongType
5656
from .css.scalar import ScalarOffset
5757
from .dom import DOMNode, NoScreen
58-
from .geometry import NULL_REGION, NULL_SPACING, Offset, Region, Size, Spacing, clamp
58+
from .geometry import (
59+
NULL_REGION,
60+
NULL_SIZE,
61+
NULL_SPACING,
62+
Offset,
63+
Region,
64+
Size,
65+
Spacing,
66+
clamp,
67+
)
5968
from .layouts.vertical import VerticalLayout
6069
from .message import Message
6170
from .messages import CallbackType
@@ -300,8 +309,9 @@ def __init__(
300309
classes: The CSS classes for the widget.
301310
disabled: Whether the widget is disabled or not.
302311
"""
303-
self._size = Size(0, 0)
304-
self._container_size = Size(0, 0)
312+
_null_size = NULL_SIZE
313+
self._size = _null_size
314+
self._container_size = _null_size
305315
self._layout_required = False
306316
self._repaint_required = False
307317
self._scroll_required = False
@@ -316,7 +326,7 @@ def __init__(
316326
self._border_title: Text | None = None
317327
self._border_subtitle: Text | None = None
318328

319-
self._render_cache = _RenderCache(Size(0, 0), [])
329+
self._render_cache = _RenderCache(_null_size, [])
320330
# Regions which need to be updated (in Widget)
321331
self._dirty_regions: set[Region] = set()
322332
# Regions which need to be transferred from cache to screen
@@ -355,8 +365,7 @@ def __init__(
355365
raise TypeError(
356366
f"Widget positional arguments must be Widget subclasses; not {child!r}"
357367
)
358-
359-
self._add_children(*children)
368+
self._pending_children = list(children)
360369
self.disabled = disabled
361370
if self.BORDER_TITLE:
362371
self.border_title = self.BORDER_TITLE
@@ -511,7 +520,7 @@ def compose_add_child(self, widget: Widget) -> None:
511520
widget: A Widget to add.
512521
"""
513522
_rich_traceback_omit = True
514-
self._nodes._append(widget)
523+
self._pending_children.append(widget)
515524

516525
def __enter__(self) -> Self:
517526
"""Use as context manager when composing."""
@@ -2974,7 +2983,7 @@ def watch_disabled(self) -> None:
29742983
and self in self.app.focused.ancestors_with_self
29752984
):
29762985
self.app.focused.blur()
2977-
except ScreenStackError:
2986+
except (ScreenStackError, NoActiveAppError):
29782987
pass
29792988
self._update_styles()
29802989

@@ -3401,9 +3410,11 @@ async def _on_key(self, event: events.Key) -> None:
34013410
async def handle_key(self, event: events.Key) -> bool:
34023411
return await self.dispatch_key(event)
34033412

3404-
async def _on_compose(self) -> None:
3413+
async def _on_compose(self, event: events.Compose) -> None:
3414+
event.prevent_default()
34053415
try:
3406-
widgets = [*self._nodes, *compose(self)]
3416+
widgets = [*self._pending_children, *compose(self)]
3417+
self._pending_children.clear()
34073418
except TypeError as error:
34083419
raise TypeError(
34093420
f"{self!r} compose() method returned an invalid result; {error}"
@@ -3414,7 +3425,19 @@ async def _on_compose(self) -> None:
34143425
self.app.panic(Traceback())
34153426
else:
34163427
self._extend_compose(widgets)
3417-
await self.mount(*widgets)
3428+
await self.mount_composed_widgets(widgets)
3429+
3430+
async def mount_composed_widgets(self, widgets: list[Widget]) -> None:
3431+
"""Called by Textual to mount widgets after compose.
3432+
3433+
There is generally no need to implement this method in your application.
3434+
See [Lazy][textual.lazy.Lazy] for a class which uses this method to implement
3435+
*lazy* mounting.
3436+
3437+
Args:
3438+
widgets: A list of child widgets.
3439+
"""
3440+
await self.mount_all(widgets)
34183441

34193442
def _extend_compose(self, widgets: list[Widget]) -> None:
34203443
"""Hook to extend composed widgets.

src/textual/widgets/_placeholder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def __init__(
121121
while next(self._variants_cycle) != self.variant:
122122
pass
123123

124-
def _on_mount(self) -> None:
124+
async def _on_compose(self, event: events.Compose) -> None:
125125
"""Set the color for this placeholder."""
126126
colors = Placeholder._COLORS.setdefault(
127127
self.app, cycle(_PLACEHOLDER_BACKGROUND_COLORS)

tests/snapshot_tests/__snapshots__/test_snapshots.ambr

Lines changed: 66 additions & 66 deletions
Large diffs are not rendered by default.

tests/test_lazy.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from textual.app import App, ComposeResult
2+
from textual.containers import Horizontal, Vertical
3+
from textual.lazy import Lazy
4+
from textual.widgets import Label
5+
6+
7+
class LazyApp(App):
8+
def compose(self) -> ComposeResult:
9+
with Vertical():
10+
with Lazy(Horizontal()):
11+
yield Label(id="foo")
12+
with Horizontal():
13+
yield Label(id="bar")
14+
15+
16+
async def test_lazy():
17+
app = LazyApp()
18+
async with app.run_test() as pilot:
19+
# No #foo on initial mount
20+
assert len(app.query("#foo")) == 0
21+
assert len(app.query("#bar")) == 1
22+
await pilot.pause()
23+
await pilot.pause()
24+
# #bar mounted after refresh
25+
assert len(app.query("#foo")) == 1
26+
assert len(app.query("#bar")) == 1

tests/test_widget.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,8 @@ def test_get_pseudo_class_state_disabled():
194194

195195
def test_get_pseudo_class_state_parent_disabled():
196196
child = Widget()
197-
_parent = Widget(child, disabled=True)
197+
_parent = Widget(disabled=True)
198+
child._attach(_parent)
198199
pseudo_classes = child.get_pseudo_class_state()
199200
assert pseudo_classes == PseudoClasses(enabled=False, focus=False, hover=False)
200201

0 commit comments

Comments
 (0)