diff --git a/CHANGELOG.md b/CHANGELOG.md index 9198a68dfd..088f7ad6ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,24 +12,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `App.batch_update` https://github.com/Textualize/textual/pull/1832 - Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832 +- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 ### Changed - Scrolling by page now adds to current position. - Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks https://github.com/Textualize/textual/pull/1832 +- Added alternative method of composing Widgets https://github.com/Textualize/textual/pull/1847 ### Removed - Removed `screen.visible_widgets` and `screen.widgets` -### Added - -- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 ### Fixed - Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836 + ## [0.11.1] - 2023-02-17 ### Fixed diff --git a/examples/code_browser.py b/examples/code_browser.py index 215b85bcf6..4616be4f73 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -38,10 +38,10 @@ def compose(self) -> ComposeResult: """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") + with Vertical(id="code-view"): + yield Static(id="code", expand=True) yield Footer() def on_mount(self, event: events.Mount) -> None: diff --git a/examples/dictionary.py b/examples/dictionary.py index f0e69d8caf..3767905452 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -20,7 +20,8 @@ class DictionaryApp(App): def compose(self) -> ComposeResult: yield Input(placeholder="Search for a word") - yield Content(Markdown(id="results"), id="results-container") + with Content(id="results-container"): + yield Static(id="results") def on_mount(self) -> None: """Called when app starts.""" diff --git a/src/textual/_compose.py b/src/textual/_compose.py new file mode 100644 index 0000000000..272e8690ae --- /dev/null +++ b/src/textual/_compose.py @@ -0,0 +1,40 @@ +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] = [] + compose_stack: list[Widget] = [] + composed: list[Widget] = [] + app._compose_stacks.append(compose_stack) + app._composed.append(composed) + try: + for child in node.compose(): + if composed: + nodes.extend(composed) + composed.clear() + if compose_stack: + compose_stack[-1]._nodes._append(child) + else: + nodes.append(child) + if composed: + nodes.extend(composed) + composed.clear() + finally: + app._compose_stacks.pop() + app._composed.pop() + return nodes diff --git a/src/textual/app.py b/src/textual/app.py index 6f6fa31fbf..318dc32239 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -52,6 +52,7 @@ 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 @@ -399,6 +400,9 @@ def __init__( self._installed_screens: dict[str, Screen | Callable[[], Screen]] = {} self._installed_screens.update(**self.SCREENS) + self._compose_stacks: list[list[Widget]] = [] + self._composed: list[list[Widget]] = [] + self.devtools: DevtoolsClient | None = None if "devtools" in self.features: try: @@ -1643,7 +1647,7 @@ async def on_screenshot(): 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 fb11f059a4..b9d8da3eba 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -41,18 +41,14 @@ def compose(self) -> ComposeResult: ] 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 + with ColorItem(classes=color): + yield ColorBar(f"${color}", classes="text label") + yield ColorBar("$text-muted", classes="muted") + yield ColorBar("$text-disabled", classes="disabled") class ColorsApp(App): diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index 38a0a9710d..204cd62463 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -73,16 +73,14 @@ def compose(self) -> ComposeResult: ) yield EasingButtons() - yield Vertical( - Horizontal( - Label("Animation Duration:", id="label"), duration_input, id="inputs" - ), - Horizontal( - self.animated_bar, - Container(self.opacity_widget, id="other"), - ), - Footer(), - ) + with Vertical(): + with Horizontal(id="inputs"): + yield Label("Animation Duration:", id="label") + yield duration_input + with Horizontal(): + yield self.animated_bar + yield Container(self.opacity_widget, id="other") + yield Footer() def on_button_pressed(self, event: Button.Pressed) -> None: self.bell() diff --git a/src/textual/widget.py b/src/textual/widget.py index 869b7e7642..2099896165 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -5,6 +5,7 @@ from fractions import Fraction from itertools import islice from operator import attrgetter +from types import TracebackType from typing import ( TYPE_CHECKING, ClassVar, @@ -33,11 +34,13 @@ from rich.style import Style from rich.text import Text from rich.traceback import Traceback +from typing_extensions import Self 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 ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING @@ -370,6 +373,25 @@ def offset(self) -> Offset: def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) + def __enter__(self) -> Self: + """Use as context manager when composing.""" + self.app._compose_stacks[-1].append(self) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit compose context manager.""" + compose_stack = self.app._compose_stacks[-1] + composed = compose_stack.pop() + if compose_stack: + compose_stack[-1]._nodes._append(composed) + else: + self.app._composed[-1].append(composed) + ExpectType = TypeVar("ExpectType", bound="Widget") @overload @@ -2497,7 +2519,7 @@ async def handle_key(self, event: events.Key) -> bool: 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}"