diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fdac88e..e8f1ec1fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Changed + +- Added alternative method of composing Widgets + ## [0.11.1] - 2023-02-17 ### Fixed diff --git a/examples/code_browser.py b/examples/code_browser.py index 215b85bcf..273a16828 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -38,10 +38,9 @@ class CodeBrowser(App): """Compose our UI.""" path = "./" if len(sys.argv) < 2 else sys.argv[1] yield Header() - yield Container( - DirectoryTree(path, id="tree-view"), - Vertical(Static(id="code", expand=True), id="code-view"), - ) + with Container(): + yield DirectoryTree(path, id="tree-view") + yield Vertical(Static(id="code", expand=True), id="code-view") yield Footer() def on_mount(self, event: events.Mount) -> None: diff --git a/src/textual/_compose.py b/src/textual/_compose.py new file mode 100644 index 000000000..a7947da7a --- /dev/null +++ b/src/textual/_compose.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .app import App + from .widget import Widget + + +def compose(node: App | Widget) -> list[Widget]: + """Compose child widgets. + + Args: + node: The parent node. + + Returns: + A list of widgets. + """ + app = node.app + nodes: list[Widget] = [] + for child in node.compose(): + if app._composed: + nodes.extend(app._composed) + app._composed.clear() + if app._compose_stack: + app._compose_stack[-1]._nodes._append(child) + else: + nodes.append(child) + if app._composed: + nodes.extend(app._composed) + app._composed.clear() + return nodes diff --git a/src/textual/app.py b/src/textual/app.py index bf33e07cd..011a6f2ad 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -46,6 +46,7 @@ from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction from ._ansi_sequences import SYNC_END, SYNC_START from ._asyncio import create_task from ._callback import invoke +from ._compose import compose from ._context import active_app from ._event_broker import NoHandler, extract_handler_actions from ._path import _make_path_object_relative @@ -388,6 +389,9 @@ class App(Generic[ReturnType], DOMNode): self._installed_screens: dict[str, Screen | Callable[[], Screen]] = {} self._installed_screens.update(**self.SCREENS) + self._compose_stack: list[Widget] = [] + self._composed: list[Widget] = [] + self.devtools: DevtoolsClient | None = None if "devtools" in self.features: try: @@ -1606,7 +1610,7 @@ class App(Generic[ReturnType], DOMNode): async def _on_compose(self) -> None: try: - widgets = list(self.compose()) + widgets = compose(self) except TypeError as error: raise TypeError( f"{self!r} compose() returned an invalid response; {error}" diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index 1cbc04f9c..fa09ba7eb 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -41,18 +41,16 @@ class ColorsView(Vertical): ] for color_name in ColorSystem.COLOR_NAMES: - items: list[Widget] = [Label(f'"{color_name}"')] - for level in LEVELS: - color = f"{color_name}-{level}" if level else color_name - item = ColorItem( - ColorBar(f"${color}", classes="text label"), - ColorBar("$text-muted", classes="muted"), - ColorBar("$text-disabled", classes="disabled"), - classes=color, - ) - items.append(item) - - yield ColorGroup(*items, id=f"group-{color_name}") + with ColorGroup(id=f"group-{color_name}"): + yield Label(f'"{color_name}"') + for level in LEVELS: + color = f"{color_name}-{level}" if level else color_name + yield ColorItem( + ColorBar(f"${color}", classes="text label"), + ColorBar("$text-muted", classes="muted"), + ColorBar("$text-disabled", classes="disabled"), + classes=color, + ) class ColorsApp(App): diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index 38a0a9710..72554bc63 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -73,16 +73,15 @@ class EasingApp(App): ) yield EasingButtons() - yield Vertical( - Horizontal( + with Vertical(): + yield Horizontal( Label("Animation Duration:", id="label"), duration_input, id="inputs" - ), - Horizontal( + ) + yield Horizontal( self.animated_bar, Container(self.opacity_widget, id="other"), - ), - Footer(), - ) + ) + yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: self.bell() diff --git a/src/textual/dom.py b/src/textual/dom.py index 77fc6c76d..78db98a29 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -134,6 +134,7 @@ class DOMNode(MessagePump): self._classes.update(_classes) self._nodes: NodeList = NodeList() + self._composing: bool = False self._css_styles: Styles = Styles(self) self._inline_styles: Styles = Styles(self) self.styles: RenderStyles = RenderStyles( diff --git a/src/textual/widget.py b/src/textual/widget.py index 0842aff93..c1ceaaa38 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -5,6 +5,7 @@ from collections import Counter from fractions import Fraction from itertools import islice from operator import attrgetter +from types import TracebackType from typing import ( TYPE_CHECKING, ClassVar, @@ -38,6 +39,7 @@ from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._asyncio import create_task +from ._compose import compose from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -363,6 +365,22 @@ class Widget(DOMNode): def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) + def __enter__(self) -> None: + self.app._compose_stack.append(self) + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + compose_stack = self.app._compose_stack + composed = compose_stack.pop() + if compose_stack: + compose_stack[-1]._nodes._append(composed) + else: + self.app._composed.append(composed) + ExpectType = TypeVar("ExpectType", bound="Widget") @overload @@ -2444,7 +2462,7 @@ class Widget(DOMNode): async def _on_compose(self) -> None: try: - widgets = list(self.compose()) + widgets = compose(self) except TypeError as error: raise TypeError( f"{self!r} compose() returned an invalid response; {error}"