return types and buttons

This commit is contained in:
Will McGugan
2022-09-05 10:51:19 +01:00
parent d945029b9a
commit 7db856d404
7 changed files with 157 additions and 41 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

44
src/textual/_compose.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"""