From 7db856d4044a7de7434676b33811cbba595c17b3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 5 Sep 2022 10:51:19 +0100 Subject: [PATCH] return types and buttons --- docs/examples/app/question01.py | 18 ++++++++++++++ docs/examples/app/question02.py | 20 +++++++++++++++ docs/guide/app.md | 31 +++++++++++++++++++++++ src/textual/_compose.py | 44 +++++++++++++++++++++++++++++++++ src/textual/app.py | 28 ++++++++------------- src/textual/widget.py | 31 +++++++++++------------ src/textual/widgets/_button.py | 26 ++++++++++++++----- 7 files changed, 157 insertions(+), 41 deletions(-) create mode 100644 docs/examples/app/question01.py create mode 100644 docs/examples/app/question02.py create mode 100644 src/textual/_compose.py diff --git a/docs/examples/app/question01.py b/docs/examples/app/question01.py new file mode 100644 index 000000000..01a70c233 --- /dev/null +++ b/docs/examples/app/question01.py @@ -0,0 +1,18 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static, Button + + +class QuestionApp(App[str]): + def compose(self) -> ComposeResult: + yield Static("Do you love Textual?") + yield Button("Yes", id="yes", variant="primary") + yield Button("No", id="no", variant="error") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.exit(event.button.id) + + +app = QuestionApp() +if __name__ == "__main__": + reply = app.run() + print(reply) diff --git a/docs/examples/app/question02.py b/docs/examples/app/question02.py new file mode 100644 index 000000000..444eaebae --- /dev/null +++ b/docs/examples/app/question02.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static, Button + + +class QuestionApp(App[str]): + def compose(self) -> ComposeResult: + yield Static("Do you love Textual?") + yield (yes := Button("Yes", id="yes")) + yes.variant = "primary" + yield (no := Button("No", id="no")) + no.variant = "error" + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.exit(event.button.id) + + +app = QuestionApp() +if __name__ == "__main__": + reply = app.run() + print(reply) diff --git a/docs/guide/app.md b/docs/guide/app.md index 5e029e72c..35aff27c0 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -107,3 +107,34 @@ When you first run this you will get a blank screen. Press any key to add the we ```{.textual path="docs/examples/app/widgets02.py" title="widgets02.py" press="a,a,a,down,down,down,down,down,down,_,_,_,_,_,_"} ``` + +### Exiting + +An app will run until you call [App.exit()](textual.app.App.exit) which will exit application mode and the [run](textual.app.App.run) method will return. If this is the last line in your code you will return to the command prompt. + +The exit method will also accept an optional positional value to be returned by `run()`. The following example uses this to return the `id` (identifier) of a clicked button. + +```python title="question01.py" +--8<-- "docs/examples/app/question01.py" +``` + +Running this app will give you the following: + +```{.textual path="docs/examples/app/question01.py"} +``` + +Clicking either of those buttons will exit the app, and the `run()` method will return either `"yes"` or `"no"` depending on button clicked. + +#### Typing + +You may have noticed that we subclassed `App[str]` rather than the usual `App`. + +```python title="question01.py" hl_lines="5" +--8<-- "docs/examples/app/question01.py" +``` + +The addition of `[str]` tells Mypy that `run()` is expected to return a string. It may also return `None` if `sys.exit()` is called without a return value, so the return type of `run` will be `str | None`. + +!!! note + + Type annotations are entirely optional (but recommended) with Textual. diff --git a/src/textual/_compose.py b/src/textual/_compose.py new file mode 100644 index 000000000..9e61cf577 --- /dev/null +++ b/src/textual/_compose.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from types import GeneratorType +from typing import TYPE_CHECKING, Iterable + +if TYPE_CHECKING: + + from .app import ComposeResult + from .widget import Widget + + +def _compose(compose_result: ComposeResult) -> Iterable[Widget]: + """Turns a compose result in to an iterable of Widgets. + + If `compose_result` is a generator, this will run the generator and send + back the yielded widgets. This allows you to write code such as this: + + ```python + yes = yield Button("Yes") + yes.variant = "success" + ``` + + Otherwise `compose_result` is assumed to already be an iterable of Widgets + and will be returned unmodified. + + Args: + compose_result (ComposeResult): Either an iterator of widgets, + or a generator. + + Returns: + Iterable[Widget]: In iterable if widgets. + + """ + + if not isinstance(compose_result, GeneratorType): + return compose_result + + try: + widget = next(compose_result) + while True: + yield widget + widget = compose_result.send(widget) + except StopIteration: + pass diff --git a/src/textual/app.py b/src/textual/app.py index cf55a57b3..8ba711ddf 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -9,21 +9,13 @@ import sys import warnings from contextlib import redirect_stderr, redirect_stdout from datetime import datetime -from pathlib import PurePath, Path +from pathlib import Path, PurePath from time import perf_counter -from typing import ( - Any, - Generic, - Iterable, - Iterator, - TextIO, - Type, - TypeVar, - cast, -) +from typing import Any, Generator, Generic, Iterable, Iterator, Type, TypeVar, cast from weakref import WeakSet, WeakValueDictionary from ._ansi_sequences import SYNC_END, SYNC_START +from ._compose import _compose from ._path import _make_path_object_relative if sys.version_info >= (3, 8): @@ -42,8 +34,8 @@ from rich.traceback import Traceback from . import ( Logger, - LogSeverity, LogGroup, + LogSeverity, LogVerbosity, actions, events, @@ -72,7 +64,6 @@ from .renderables.blank import Blank from .screen import Screen from .widget import Widget - PLATFORM = platform.system() WINDOWS = PLATFORM == "Windows" @@ -107,7 +98,7 @@ DEFAULT_COLORS = { } -ComposeResult = Iterable[Widget] +ComposeResult = Iterable[Widget] | Generator[Widget, Widget, None] class AppError(Exception): @@ -395,6 +386,9 @@ class App(Generic[ReturnType], DOMNode): return yield + def _compose(self) -> Iterable[Widget]: + return _compose(self.compose()) + def get_css_variables(self) -> dict[str, str]: """Get a mapping of variables used to pre-populate CSS. @@ -1155,7 +1149,7 @@ class App(Generic[ReturnType], DOMNode): self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") def _on_mount(self) -> None: - widgets = self.compose() + widgets = self._compose() if widgets: self.mount_all(widgets) @@ -1189,9 +1183,7 @@ class App(Generic[ReturnType], DOMNode): parent (Widget): Parent Widget """ if not anon_widgets and not widgets: - raise AppError( - "Nothing to mount, did you forget parent as first positional arg?" - ) + return name_widgets: Iterable[tuple[str | None, Widget]] name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()] apply_stylesheet = self.stylesheet.apply diff --git a/src/textual/widget.py b/src/textual/widget.py index a42f5c833..63a332447 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,16 +4,11 @@ from asyncio import Lock from fractions import Fraction from itertools import islice from operator import attrgetter -from typing import ( - TYPE_CHECKING, - ClassVar, - Collection, - Iterable, - NamedTuple, -) +from types import GeneratorType +from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple import rich.repr -from rich.console import Console, RenderableType, JustifyMethod +from rich.console import Console, JustifyMethod, RenderableType from rich.measure import Measurement from rich.segment import Segment from rich.style import Style @@ -22,7 +17,8 @@ from rich.text import Text from . import errors, events, messages from ._animator import BoundAnimator -from ._arrange import arrange, DockArrangeResult +from ._arrange import DockArrangeResult, arrange +from ._compose import _compose from ._context import active_app from ._layout import Layout from ._segment_tools import align_lines @@ -30,8 +26,7 @@ from ._styles_cache import StylesCache from ._types import Lines from .box_model import BoxModel, get_box_model from .css.constants import VALID_TEXT_ALIGN -from .dom import DOMNode -from .dom import NoScreen +from .dom import DOMNode, NoScreen from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message @@ -41,12 +36,12 @@ if TYPE_CHECKING: from .app import App, ComposeResult from .scrollbar import ( ScrollBar, + ScrollBarCorner, ScrollDown, ScrollLeft, ScrollRight, ScrollTo, ScrollUp, - ScrollBarCorner, ) @@ -239,7 +234,7 @@ class Widget(DOMNode): # reset the scroll position if the scrollbar is hidden. self.scroll_to(0, 0, animate=False) - def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: + def mount(self, *anon_widgets: Widget, **widgets: Widget) -> int: """Mount child widgets (making this widget a container). Widgets may be passed as positional arguments or keyword arguments. If keyword arguments, @@ -273,6 +268,9 @@ class Widget(DOMNode): return yield + def _compose(self) -> Iterable[Widget]: + return _compose(self.compose()) + def _post_register(self, app: App) -> None: """Called when the instance is registered. @@ -1458,10 +1456,9 @@ class Widget(DOMNode): await self.dispatch_key(event) def _on_mount(self, event: events.Mount) -> None: - widgets = list(self.compose()) - if widgets: - self.mount(*widgets) - self.screen.refresh(repaint=False, layout=True) + widgets = self._compose() + self.mount(*widgets) + self.screen.refresh(repaint=False, layout=True) def _on_leave(self, event: events.Leave) -> None: self.mouse_over = False diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index f8fcc229f..d2088cfc9 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -44,6 +44,10 @@ class Button(Widget, can_focus=True): text-style: bold; } + Button.-disabled { + opacity: 0.6; + } + Button:focus { text-style: bold reverse; } @@ -183,22 +187,32 @@ class Button(Widget, can_focus=True): if label is None: label = self.css_identifier_styled - self.label: Text = label + self.label = label self.disabled = disabled if disabled: self.add_class("-disabled") - if variant in _VALID_BUTTON_VARIANTS: - if variant != "default": - self.add_class(f"-{variant}") + self.variant = variant - else: + label: Reactive[RenderableType] = Reactive("") + variant = Reactive.init("default") + disabled = Reactive(False) + + def validate_variant(self, variant: str) -> str: + if variant not in _VALID_BUTTON_VARIANTS: raise InvalidButtonVariant( f"Valid button variants are {friendly_list(_VALID_BUTTON_VARIANTS)}" ) + return variant - label: Reactive[RenderableType] = Reactive("") + def watch_variant(self, old_variant: str, variant: str): + self.remove_class(f"_{old_variant}") + self.add_class(f"-{variant}") + + def watch_disabled(self, disabled: bool) -> None: + self.set_class(disabled, "-disabled") + self.can_focus = not disabled def validate_label(self, label: RenderableType) -> RenderableType: """Parse markup for self.label"""