Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lazy mount #3936

Merged
merged 9 commits into from
Jan 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading