Skip to content

Commit

Permalink
lazy mount (#3936)
Browse files Browse the repository at this point in the history
* lazy mount

* Lazy test

* doc

* Add to docs

* snapshot and changelog

* typing

* future

* less flaky

* comment
  • Loading branch information
willmcgugan authored Jan 1, 2024
1 parent 244e42c commit b8fccd4
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 79 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,9 @@ def reset_styles(self) -> None:
def _add_child(self, node: Widget) -> None:
"""Add a new child node.
!!! note
For tests only.
Args:
node: A DOM node.
"""
Expand All @@ -1006,13 +1009,17 @@ def _add_child(self, node: Widget) -> None:
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.
"""
_append = self._nodes._append
for node in nodes:
node._attach(self)
_append(node)
node._add_children(*node._pending_children)

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

Expand Down
3 changes: 3 additions & 0 deletions src/textual/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,5 +1152,8 @@ def grow_maximum(self, other: Spacing) -> Spacing:
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 changes: 65 additions & 0 deletions src/textual/lazy.py
Original file line number Diff line number Diff line change
@@ -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)
45 changes: 34 additions & 11 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,16 @@
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
Expand Down Expand Up @@ -300,8 +309,9 @@ def __init__(
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
Expand All @@ -316,7 +326,7 @@ def __init__(
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
Expand Down Expand Up @@ -355,8 +365,7 @@ def __init__(
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
Expand Down Expand Up @@ -511,7 +520,7 @@ def compose_add_child(self, widget: Widget) -> None:
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."""
Expand Down Expand Up @@ -2974,7 +2983,7 @@ def watch_disabled(self) -> None:
and self in self.app.focused.ancestors_with_self
):
self.app.focused.blur()
except ScreenStackError:
except (ScreenStackError, NoActiveAppError):
pass
self._update_styles()

Expand Down Expand Up @@ -3401,9 +3410,11 @@ async def _on_key(self, event: events.Key) -> None:
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}"
Expand All @@ -3414,7 +3425,19 @@ async def _on_compose(self) -> None:
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.
Expand Down
2 changes: 1 addition & 1 deletion src/textual/widgets/_placeholder.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ def __init__(
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)
Expand Down
132 changes: 66 additions & 66 deletions tests/snapshot_tests/__snapshots__/test_snapshots.ambr

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions tests/test_lazy.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down

0 comments on commit b8fccd4

Please sign in to comment.