From f2f60944b52436209106ca7ee0788fd06f11ef06 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 29 Dec 2023 19:58:54 +0000 Subject: [PATCH 1/9] lazy mount --- src/textual/lazy.py | 28 ++++++++++++++++++++++++++++ src/textual/widget.py | 24 ++++++++++++++++++------ 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 src/textual/lazy.py diff --git a/src/textual/lazy.py b/src/textual/lazy.py new file mode 100644 index 0000000000..091e31c088 --- /dev/null +++ b/src/textual/lazy.py @@ -0,0 +1,28 @@ +from .widget import Widget + + +class Lazy(Widget): + DEFAULT_CSS = """ + Lazy { + display: none; + } + """ + + def __init__(self, widget: Widget) -> None: + 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: + await parent.mount(self._replace_widget, after=self) + await self.remove() + + self.call_after_refresh(mount) diff --git a/src/textual/widget.py b/src/textual/widget.py index 4ece3935b3..6005577c55 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -355,8 +355,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 @@ -511,7 +510,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.""" @@ -3401,9 +3400,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}" @@ -3414,7 +3415,18 @@ 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. + + It is rare to need to define this in a typical app. One use for this, + is to defer the mounting until later. + + 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. From 03cdab830708ebfaed507e4cd2c296659804c912 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 13:26:47 +0000 Subject: [PATCH 2/9] Lazy test --- src/textual/dom.py | 7 +++++++ src/textual/lazy.py | 27 +++++++++++++++++++++++++++ src/textual/widget.py | 2 +- src/textual/widgets/_placeholder.py | 2 +- tests/test_lazy.py | 25 +++++++++++++++++++++++++ tests/test_widget.py | 3 ++- 6 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/test_lazy.py diff --git a/src/textual/dom.py b/src/textual/dom.py index da21045e4e..a9c832788e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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. """ @@ -1006,6 +1009,9 @@ 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. """ @@ -1013,6 +1019,7 @@ def _add_children(self, *nodes: Widget) -> None: for node in nodes: node._attach(self) _append(node) + node._add_children(*node._pending_children) WalkType = TypeVar("WalkType", bound="DOMNode") diff --git a/src/textual/lazy.py b/src/textual/lazy.py index 091e31c088..75c923f638 100644 --- a/src/textual/lazy.py +++ b/src/textual/lazy.py @@ -2,6 +2,27 @@ 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. + + 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; @@ -9,6 +30,11 @@ class Lazy(Widget): """ 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__() @@ -22,6 +48,7 @@ async def mount_composed_widgets(self, widgets: list[Widget]) -> None: 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() diff --git a/src/textual/widget.py b/src/textual/widget.py index 6005577c55..83f66a08a3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2973,7 +2973,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() diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 9f4590e37e..9c9adf725c 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -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) diff --git a/tests/test_lazy.py b/tests/test_lazy.py new file mode 100644 index 0000000000..815dc64ab2 --- /dev/null +++ b/tests/test_lazy.py @@ -0,0 +1,25 @@ +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() + # #bar mounted after refresh + assert len(app.query("#foo")) == 1 + assert len(app.query("#bar")) == 1 diff --git a/tests/test_widget.py b/tests/test_widget.py index a3b77caad9..82524ac11f 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -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) From 63d09fcd96392e7524ee539af24953ab56b0ef89 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 13:30:07 +0000 Subject: [PATCH 3/9] doc --- src/textual/lazy.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/lazy.py b/src/textual/lazy.py index 75c923f638..06e6129de9 100644 --- a/src/textual/lazy.py +++ b/src/textual/lazy.py @@ -11,6 +11,9 @@ class Lazy(Widget): 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 From cfbd44e79cf1d4ed295a0e910b374ec2a71b5e1e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 13:31:10 +0000 Subject: [PATCH 4/9] Add to docs --- mkdocs-nav.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 28116f740c..68f1723917 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -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" From bcb511cf6f5ddd6ac17854b395a18c2155432a39 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 13:35:12 +0000 Subject: [PATCH 5/9] snapshot and changelog --- CHANGELOG.md | 4 + .../__snapshots__/test_snapshots.ambr | 132 +++++++++--------- 2 files changed, 70 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7f090452..16298bde83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 8926981f88..246fc4c553 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -25864,142 +25864,142 @@ font-weight: 700; } - .terminal-700023403-matrix { + .terminal-3476127971-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-700023403-title { + .terminal-3476127971-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-700023403-r1 { fill: #c5c8c6 } - .terminal-700023403-r2 { fill: #eae3e5 } - .terminal-700023403-r3 { fill: #e8e0e7 } - .terminal-700023403-r4 { fill: #efe9e4 } - .terminal-700023403-r5 { fill: #ede6e6 } - .terminal-700023403-r6 { fill: #efeedf } - .terminal-700023403-r7 { fill: #e9eee5 } - .terminal-700023403-r8 { fill: #e2edeb } - .terminal-700023403-r9 { fill: #e4eee8;font-weight: bold } - .terminal-700023403-r10 { fill: #dfebed;font-weight: bold } - .terminal-700023403-r11 { fill: #dfe9ed } - .terminal-700023403-r12 { fill: #e3e6eb;font-weight: bold } - .terminal-700023403-r13 { fill: #e6e3e9 } + .terminal-3476127971-r1 { fill: #c5c8c6 } + .terminal-3476127971-r2 { fill: #eae3e5 } + .terminal-3476127971-r3 { fill: #e8e0e7 } + .terminal-3476127971-r4 { fill: #efe9e4 } + .terminal-3476127971-r5 { fill: #ede6e6 } + .terminal-3476127971-r6 { fill: #efeedf } + .terminal-3476127971-r7 { fill: #e9eee5 } + .terminal-3476127971-r8 { fill: #e3e6eb } + .terminal-3476127971-r9 { fill: #dfe9ed;font-weight: bold } + .terminal-3476127971-r10 { fill: #e6e3e9;font-weight: bold } + .terminal-3476127971-r11 { fill: #e4eee8 } + .terminal-3476127971-r12 { fill: #e2edeb;font-weight: bold } + .terminal-3476127971-r13 { fill: #dfebed } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - PlaceholderApp + PlaceholderApp - - - - - Placeholder p2 here! - This is a custom label for p1. - #p4 - #p3#p5Placeholde - r - - Lorem ipsum dolor sit  - 26 x 6amet, consectetur 27 x 6 - adipiscing elit. Etiam  - feugiat ac elit sit amet  - - - Lorem ipsum dolor sit amet,  - consectetur adipiscing elit. Etiam 40 x 6 - feugiat ac elit sit amet accumsan.  - Suspendisse bibendum nec libero quis  - gravida. Phasellus id eleifend ligula. - Nullam imperdiet sem tellus, sed  - vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  - Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  - lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  - sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  + + + + + Placeholder p2 here! + This is a custom label for p1. + #p4 + #p3#p5Placeholde + r + + Lorem ipsum dolor sit  + 26 x 6amet, consectetur 27 x 6 + adipiscing elit. Etiam  + feugiat ac elit sit amet  + + + Lorem ipsum dolor sit amet,  + consectetur adipiscing elit. Etiam 40 x 6 + feugiat ac elit sit amet accumsan.  + Suspendisse bibendum nec libero quis  + gravida. Phasellus id eleifend ligula. + Nullam imperdiet sem tellus, sed  + vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  + Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  + lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  + sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  From 2a0da23dcefbcf1eba610ad420b42e4719609dab Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 13:38:20 +0000 Subject: [PATCH 6/9] typing --- src/textual/lazy.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/textual/lazy.py b/src/textual/lazy.py index 06e6129de9..0188eb43be 100644 --- a/src/textual/lazy.py +++ b/src/textual/lazy.py @@ -1,3 +1,10 @@ +""" +Tools for lazy loading widgets. +""" + + +from __future__ import __annotations__ + from .widget import Widget From 49614943dc5ca78759398be4124878bf57885b83 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 13:39:34 +0000 Subject: [PATCH 7/9] future --- src/textual/lazy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/lazy.py b/src/textual/lazy.py index 0188eb43be..ef8d6bfbd4 100644 --- a/src/textual/lazy.py +++ b/src/textual/lazy.py @@ -3,7 +3,7 @@ """ -from __future__ import __annotations__ +from __future__ import annotations from .widget import Widget From 059bdb86a9c0bbae516ce48a1b30882173136ca7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 13:49:22 +0000 Subject: [PATCH 8/9] less flaky --- tests/test_lazy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_lazy.py b/tests/test_lazy.py index 815dc64ab2..a399b1af7f 100644 --- a/tests/test_lazy.py +++ b/tests/test_lazy.py @@ -20,6 +20,7 @@ async def test_lazy(): 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 From 707d01ee2d0f5612b9bc25b0294c3a3b43993c24 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 30 Dec 2023 19:47:41 +0000 Subject: [PATCH 9/9] comment --- src/textual/geometry.py | 3 +++ src/textual/widget.py | 25 ++++++++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 66d1ed3cd4..8727e19c0b 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 83f66a08a3..b4070a5085 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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 @@ -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 @@ -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 @@ -3418,10 +3428,11 @@ async def _on_compose(self, event: events.Compose) -> None: await self.mount_composed_widgets(widgets) async def mount_composed_widgets(self, widgets: list[Widget]) -> None: - """Called by textual to mount widgets after compose. + """Called by Textual to mount widgets after compose. - It is rare to need to define this in a typical app. One use for this, - is to defer the mounting until later. + 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.