From 347b94a0fe927afb690ae241fc320d98f1549389 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 16:16:10 +0000 Subject: [PATCH 01/11] changelog --- CHANGELOG.md | 6 ++++++ examples/code_browser.py | 7 +++---- src/textual/_compose.py | 32 ++++++++++++++++++++++++++++++ src/textual/app.py | 6 +++++- src/textual/cli/previews/colors.py | 22 ++++++++++---------- src/textual/cli/previews/easing.py | 13 ++++++------ src/textual/dom.py | 1 + src/textual/widget.py | 20 ++++++++++++++++++- 8 files changed, 82 insertions(+), 25 deletions(-) create mode 100644 src/textual/_compose.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fdac88e5..e8f1ec1fd8 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 215b85bcf6..273a168283 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -38,10 +38,9 @@ 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") + 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 0000000000..a7947da7aa --- /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 bf33e07cd9..011a6f2adc 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -46,6 +46,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 @@ -388,6 +389,9 @@ def __init__( 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 @@ 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 1cbc04f9c8..fa09ba7eb8 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -41,18 +41,16 @@ 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 + 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 38a0a9710d..72554bc63e 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -73,16 +73,15 @@ def compose(self) -> ComposeResult: ) 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 77fc6c76d6..78db98a291 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -134,6 +134,7 @@ def __init__( 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 0842aff932..c1ceaaa385 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, @@ -38,6 +39,7 @@ 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 @@ def offset(self) -> Offset: 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 @@ 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}" From 14bbe0445e690d29ab55f635664543b59b6c839f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 16:26:12 +0000 Subject: [PATCH 02/11] context compose --- examples/code_browser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/code_browser.py b/examples/code_browser.py index 273a168283..4616be4f73 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -40,7 +40,8 @@ def compose(self) -> ComposeResult: yield Header() with Container(): yield DirectoryTree(path, id="tree-view") - yield Vertical(Static(id="code", expand=True), id="code-view") + with Vertical(id="code-view"): + yield Static(id="code", expand=True) yield Footer() def on_mount(self, event: events.Mount) -> None: From c3e84ab9d0c5cf16bc37ac86ecd8341967315974 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 16:27:24 +0000 Subject: [PATCH 03/11] context --- src/textual/cli/previews/colors.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index fa09ba7eb8..5423f2e3e8 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -45,12 +45,10 @@ def compose(self) -> ComposeResult: 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, - ) + 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): From 3d1b3ce7e9b68fe396877558464e8808c6bebc95 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 16:29:03 +0000 Subject: [PATCH 04/11] syntax --- src/textual/cli/previews/easing.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index 72554bc63e..204cd62463 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -74,13 +74,12 @@ def compose(self) -> ComposeResult: yield EasingButtons() with Vertical(): - yield Horizontal( - Label("Animation Duration:", id="label"), duration_input, id="inputs" - ) - yield Horizontal( - self.animated_bar, - Container(self.opacity_widget, id="other"), - ) + 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: From 55320751302eaac2495617c6913d70234ca8d488 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 16:29:41 +0000 Subject: [PATCH 05/11] remove bool --- src/textual/dom.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 78db98a291..77fc6c76d6 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -134,7 +134,6 @@ def __init__( 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( From bd2bbc162516d5164df965410ddbf09f2744e7f1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 16:49:17 +0000 Subject: [PATCH 06/11] Allow nesting --- src/textual/_compose.py | 25 ++++++++++++++++--------- src/textual/app.py | 4 ++-- src/textual/widget.py | 6 +++--- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index a7947da7aa..0668eca542 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -18,15 +18,22 @@ def compose(node: App | Widget) -> list[Widget]: """ app = node.app nodes: list[Widget] = [] - for child in node.compose(): + compose_stack: list[Widget] = [] + composed: list[Widget] = [] + app._compose_stacks.append(compose_stack) + app._composed.append(composed) + try: + for child in node.compose(): + if app._composed: + nodes.extend(composed) + composed.clear() + if compose_stack: + compose_stack[-1]._nodes._append(child) + else: + nodes.append(child) if app._composed: - nodes.extend(app._composed) + nodes.extend(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() + finally: + app._compose_stacks.pop() return nodes diff --git a/src/textual/app.py b/src/textual/app.py index 011a6f2adc..5572a10cf6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -389,8 +389,8 @@ def __init__( self._installed_screens: dict[str, Screen | Callable[[], Screen]] = {} self._installed_screens.update(**self.SCREENS) - self._compose_stack: list[Widget] = [] - self._composed: list[Widget] = [] + self._compose_stacks: list[list[Widget]] = [] + self._composed: list[list[Widget]] = [] self.devtools: DevtoolsClient | None = None if "devtools" in self.features: diff --git a/src/textual/widget.py b/src/textual/widget.py index c1ceaaa385..b1c567b1c9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -366,7 +366,7 @@ def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) def __enter__(self) -> None: - self.app._compose_stack.append(self) + self.app._compose_stacks[-1].append(self) def __exit__( self, @@ -374,12 +374,12 @@ def __exit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: - compose_stack = self.app._compose_stack + compose_stack = self.app._compose_stacks[-1] composed = compose_stack.pop() if compose_stack: compose_stack[-1]._nodes._append(composed) else: - self.app._composed.append(composed) + self.app._composed[-1].append(composed) ExpectType = TypeVar("ExpectType", bound="Widget") From aaf9431e6dc6b07c45f47e7aad824e65dc563263 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 17:04:00 +0000 Subject: [PATCH 07/11] nesting fix --- src/textual/_compose.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 0668eca542..313461f938 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -24,16 +24,16 @@ def compose(node: App | Widget) -> list[Widget]: app._composed.append(composed) try: for child in node.compose(): - if app._composed: + if composed: nodes.extend(composed) composed.clear() if compose_stack: compose_stack[-1]._nodes._append(child) else: nodes.append(child) - if app._composed: + if composed: nodes.extend(composed) - app._composed.clear() + composed.clear() finally: app._compose_stacks.pop() return nodes From dfbd933987b1a57d7df680407caea50eb503db2c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 17:47:32 +0000 Subject: [PATCH 08/11] Return self --- src/textual/widget.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index b1c567b1c9..206c3d5267 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -34,6 +34,7 @@ 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 @@ -365,8 +366,10 @@ def offset(self) -> Offset: def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) - def __enter__(self) -> None: + def __enter__(self) -> Self: + """Use as context manager when composing.""" self.app._compose_stacks[-1].append(self) + return self def __exit__( self, @@ -374,6 +377,7 @@ def __exit__( 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: From 9045f138371636151ce4524202a4ed6cf5a45e44 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 18:03:36 +0000 Subject: [PATCH 09/11] context syntax --- examples/dictionary.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index 737bcb283b..97902e4b3c 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -11,7 +11,7 @@ from textual.app import App, ComposeResult from textual.containers import Content -from textual.widgets import Static, Input +from textual.widgets import Input, Static class DictionaryApp(App): @@ -21,7 +21,8 @@ class DictionaryApp(App): def compose(self) -> ComposeResult: yield Input(placeholder="Search for a word") - yield Content(Static(id="results"), id="results-container") + with Content(id="results-container"): + yield Static(id="results") def on_mount(self) -> None: """Called when app starts.""" From 283e0340935ff88e9585fff6c251d8b443a6c2b1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 20:49:06 +0000 Subject: [PATCH 10/11] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f1ec1fd8..186054a1fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- Added alternative method of composing Widgets +- Added alternative method of composing Widgets https://github.com/Textualize/textual/pull/1847 ## [0.11.1] - 2023-02-17 From 2f0918478bcf1defea9ac4e3dd77024e8e387b1f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 21:33:50 +0000 Subject: [PATCH 11/11] missed pop --- src/textual/_compose.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 313461f938..272e8690ae 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -36,4 +36,5 @@ def compose(node: App | Widget) -> list[Widget]: composed.clear() finally: app._compose_stacks.pop() + app._composed.pop() return nodes